Migrate UI to Jetpack Compose with Material 3 design

- Replace XML layouts with Compose screens for all 5 Activities
- Implement Material 3 theme with Google Blue/Green color scheme matching app icon
- Add device-aware navigation with bottom tabs for Samsung devices
- Remove action bar for modern edge-to-edge UI design
- Update Kotlin to 1.9.22 for Compose Compiler compatibility
- Add Compose dependencies and configure build system
- Implement MainViewModel for upload state management
- Create reusable dialog components for upload parameters and results
- Preserve all existing Java controllers and services (UI-only migration)
This commit is contained in:
2025-12-27 18:47:29 -05:00
parent 7b66e8cf68
commit 2c8315cb5d
20 changed files with 2544 additions and 7 deletions

View File

@@ -39,6 +39,14 @@ android {
languageVersion.set(JavaLanguageVersion.of(17))
}
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = '1.5.8'
}
}
dependencies {
@@ -77,6 +85,23 @@ dependencies {
// Coroutines
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"
// Jetpack Compose
def composeBom = platform('androidx.compose:compose-bom:2024.12.01')
implementation composeBom
implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.ui:ui-tooling-preview'
implementation 'androidx.compose.material3:material3'
implementation 'androidx.compose.material:material-icons-extended'
implementation 'androidx.activity:activity-compose:1.9.3'
implementation 'androidx.navigation:navigation-compose:2.8.5'
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7'
implementation 'androidx.lifecycle:lifecycle-runtime-compose:2.8.7'
implementation 'androidx.compose.runtime:runtime-livedata'
// Compose Debug
debugImplementation 'androidx.compose.ui:ui-tooling'
debugImplementation 'androidx.compose.ui:ui-test-manifest'
// Testing
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.2.1'

View File

@@ -22,22 +22,25 @@
android:usesCleartextTraffic="false"
android:theme="@style/Theme.AppCompat.DayNight.DarkActionBar">
<!-- Main Activity -->
<!-- NEW: Compose Main Activity (LAUNCHER) -->
<activity
android:name=".view.MainActivity"
android:exported="true">
android:name=".ComposeMainActivity"
android:exported="true"
android:theme="@style/Theme.AppCompat.Light.NoActionBar">
<!-- LAUNCHER - App starts here -->
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Share intent filter for files -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
<!-- Share intent filter for multiple files -->
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
@@ -45,7 +48,14 @@
<data android:mimeType="*/*" />
</intent-filter>
</activity>
<!-- OLD: Legacy MainActivity (DEPRECATED - kept for reference only) -->
<activity
android:name=".view.MainActivity"
android:exported="false">
<!-- Removed LAUNCHER intent - now using ComposeMainActivity -->
</activity>
<!-- Upload History Activity -->
<activity
android:name=".view.UploadHistoryActivity"

View File

@@ -0,0 +1,99 @@
package com.mattintech.simplelogupload
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import androidx.navigation.compose.rememberNavController
import com.mattintech.simplelogupload.navigation.AppNavigation
import com.mattintech.simplelogupload.navigation.Screen
import com.mattintech.simplelogupload.ui.theme.SimpleLogUploadTheme
import com.mattintech.simplelogupload.util.PreferencesUtil
/**
* Main Activity for the Compose-based UI
* Replaces all previous XML-based Activities with a single Compose Activity
*/
class ComposeMainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Enable edge-to-edge display for modern UI
enableEdgeToEdge()
// Determine start destination based on setup status
val startDestination = getStartDestination()
// Handle share intents if this was launched from external app
val sharedFileUri = handleShareIntent(intent)
setContent {
SimpleLogUploadTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
val navController = rememberNavController()
AppNavigation(
navController = navController,
startDestination = startDestination
)
}
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
// Handle new share intents when activity is already running
handleShareIntent(intent)
}
/**
* Determine which screen to show first based on app configuration
*/
private fun getStartDestination(): String {
return if (PreferencesUtil.isSetupCompleted(this) &&
PreferencesUtil.areServerSettingsConfigured(this)) {
Screen.Main.route
} else {
Screen.Welcome.route
}
}
/**
* Handle share intents from external apps
*
* @param intent The intent that launched this activity
* @return Uri of shared file, or null if not a share intent
*/
private fun handleShareIntent(intent: Intent?): Uri? {
if (intent?.action == Intent.ACTION_SEND) {
if (intent.type?.startsWith("text/") == true) {
// Handle shared text
val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT)
// TODO: Handle shared text if needed
return null
} else {
// Handle shared file
val sharedUri = intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)
// TODO: Pass this URI to MainScreen/MainViewModel for upload
return sharedUri
}
} else if (intent?.action == Intent.ACTION_SEND_MULTIPLE) {
// Handle multiple shared files
val sharedUris = intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)
// TODO: Handle multiple files
return sharedUris?.firstOrNull()
}
return null
}
}

View File

@@ -0,0 +1,82 @@
package com.mattintech.simplelogupload.navigation
import androidx.compose.runtime.Composable
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import com.mattintech.simplelogupload.ui.screens.WelcomeScreen
import com.mattintech.simplelogupload.ui.screens.ServerSetupScreen
import com.mattintech.simplelogupload.ui.screens.MainScreen
import com.mattintech.simplelogupload.ui.screens.UploadHistoryScreen
import com.mattintech.simplelogupload.ui.screens.SettingsScreen
/**
* Main navigation graph for the app
*
* @param navController Navigation controller
* @param startDestination Initial screen to show
*/
@Composable
fun AppNavigation(
navController: NavHostController,
startDestination: String
) {
NavHost(
navController = navController,
startDestination = startDestination
) {
// Welcome screen - shown on first launch
composable(Screen.Welcome.route) {
WelcomeScreen(
onNavigateToSetup = {
navController.navigate(Screen.ServerSetup.route)
}
)
}
// Server setup screen - configure server connection
composable(Screen.ServerSetup.route) {
ServerSetupScreen(
onSetupComplete = {
navController.navigate(Screen.Main.route) {
// Clear backstack so user can't go back to setup
popUpTo(Screen.Welcome.route) { inclusive = true }
}
},
onNavigateBack = {
navController.popBackStack()
}
)
}
// Main screen - primary upload functionality
composable(Screen.Main.route) {
MainScreen(
onNavigateToHistory = {
navController.navigate(Screen.UploadHistory.route)
},
onNavigateToSettings = {
navController.navigate(Screen.Settings.route)
}
)
}
// Upload history screen
composable(Screen.UploadHistory.route) {
UploadHistoryScreen(
onNavigateBack = {
navController.popBackStack()
}
)
}
// Settings screen
composable(Screen.Settings.route) {
SettingsScreen(
onNavigateBack = {
navController.popBackStack()
}
)
}
}
}

View File

@@ -0,0 +1,31 @@
package com.mattintech.simplelogupload.navigation
/**
* Sealed class representing all navigation destinations in the app
*/
sealed class Screen(val route: String) {
/**
* Welcome/onboarding screen shown on first launch
*/
data object Welcome : Screen("welcome")
/**
* Server setup screen for configuring server connection
*/
data object ServerSetup : Screen("server_setup")
/**
* Main screen with upload functionality and bottom navigation
*/
data object Main : Screen("main")
/**
* Upload history screen showing past uploads
*/
data object UploadHistory : Screen("upload_history")
/**
* Settings screen for app configuration
*/
data object Settings : Screen("settings")
}

View File

@@ -0,0 +1,106 @@
package com.mattintech.simplelogupload.ui.components
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
/**
* Dialog for entering upload parameters
*
* @param onDismiss Callback when dialog is dismissed
* @param onConfirm Callback when upload is confirmed with (maxDownloads, expiryHours)
*/
@Composable
fun UploadParamsDialog(
onDismiss: () -> Unit,
onConfirm: (Int, Int) -> Unit
) {
var maxDownloads by remember { mutableStateOf("5") }
var expiryHours by remember { mutableStateOf("24") }
var maxDownloadsError by remember { mutableStateOf<String?>(null) }
var expiryHoursError by remember { mutableStateOf<String?>(null) }
fun validateAndConfirm() {
maxDownloadsError = null
expiryHoursError = null
var hasError = false
val maxDl = maxDownloads.toIntOrNull()
if (maxDl == null || maxDl < 0) {
maxDownloadsError = "Must be 0 or greater (0 = unlimited)"
hasError = true
}
val expiry = expiryHours.toIntOrNull()
if (expiry == null || expiry <= 0 || expiry > 24) {
expiryHoursError = "Must be between 1 and 24 hours"
hasError = true
}
if (!hasError) {
onConfirm(maxDl!!, expiry!!)
}
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Upload Parameters") },
text = {
Column(
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Max Downloads
OutlinedTextField(
value = maxDownloads,
onValueChange = {
maxDownloads = it
maxDownloadsError = null
},
label = { Text("Maximum Downloads") },
placeholder = { Text("Default: 5") },
supportingText = {
Text(maxDownloadsError ?: "Set to 0 for unlimited downloads")
},
isError = maxDownloadsError != null,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
// Expiry Hours
OutlinedTextField(
value = expiryHours,
onValueChange = {
expiryHours = it
expiryHoursError = null
},
label = { Text("Expiry Time (hours)") },
placeholder = { Text("Default: 24") },
supportingText = {
Text(expiryHoursError ?: "Maximum 24 hours (1 day)")
},
isError = expiryHoursError != null,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
}
},
confirmButton = {
Button(onClick = { validateAndConfirm() }) {
Text("Upload")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}

View File

@@ -0,0 +1,105 @@
package com.mattintech.simplelogupload.ui.components
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.widget.Toast
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
/**
* Dialog for displaying upload success result with upload code
*
* @param uploadCode The upload code to display
* @param onDismiss Callback when dialog is dismissed
*/
@Composable
fun UploadResultDialog(
uploadCode: String,
onDismiss: () -> Unit
) {
val context = LocalContext.current
fun copyCodeToClipboard() {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("Upload Code", uploadCode)
clipboard.setPrimaryClip(clip)
Toast.makeText(context, "Code copied to clipboard", Toast.LENGTH_SHORT).show()
}
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
text = "Upload Successful!",
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
},
text = {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "Your upload code is:",
style = MaterialTheme.typography.bodyMedium
)
// Upload code in a card
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) {
Row(
modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = uploadCode,
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.weight(1f),
textAlign = TextAlign.Center
)
IconButton(onClick = { copyCodeToClipboard() }) {
Icon(
Icons.Default.ContentCopy,
contentDescription = "Copy code",
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
}
Text(
text = "Share this code to allow others to download your file.",
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
confirmButton = {
Button(
onClick = onDismiss,
modifier = Modifier.fillMaxWidth()
) {
Text("Close")
}
}
)
}

View File

@@ -0,0 +1,112 @@
package com.mattintech.simplelogupload.ui.screens
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Upload
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel
import com.mattintech.simplelogupload.ui.screens.tabs.DumpstateTab
import com.mattintech.simplelogupload.ui.screens.tabs.GeneralUploadTab
import com.mattintech.simplelogupload.util.DeviceUtil
import com.mattintech.simplelogupload.viewmodel.MainViewModel
/**
* Main screen with upload functionality and bottom navigation
* Shows different tabs based on device type (Samsung vs others)
*
* @param onNavigateToHistory Callback to navigate to upload history
* @param onNavigateToSettings Callback to navigate to settings
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen(
onNavigateToHistory: () -> Unit,
onNavigateToSettings: () -> Unit,
viewModel: MainViewModel = viewModel()
) {
val isSamsungDevice = remember { DeviceUtil.isSamsungDevice() }
var selectedTabIndex by remember { mutableStateOf(0) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("SimpleLogUpload") },
actions = {
// History button
IconButton(onClick = onNavigateToHistory) {
Icon(
Icons.Default.History,
contentDescription = "Upload History"
)
}
// Settings button
IconButton(onClick = onNavigateToSettings) {
Icon(
Icons.Default.Settings,
contentDescription = "Settings"
)
}
}
)
},
bottomBar = {
// Only show bottom navigation on Samsung devices
if (isSamsungDevice) {
NavigationBar {
NavigationBarItem(
selected = selectedTabIndex == 0,
onClick = { selectedTabIndex = 0 },
icon = {
Icon(
Icons.Default.Upload,
contentDescription = "General Upload"
)
},
label = { Text("Upload") }
)
NavigationBarItem(
selected = selectedTabIndex == 1,
onClick = { selectedTabIndex = 1 },
icon = {
Icon(
Icons.Default.BugReport,
contentDescription = "Dumpstate"
)
},
label = { Text("Dumpstate") }
)
}
}
}
) { paddingValues ->
when {
// Non-Samsung device: show only general upload
!isSamsungDevice -> {
GeneralUploadTab(
modifier = Modifier.padding(paddingValues),
viewModel = viewModel
)
}
// Samsung device with tab 0 selected: general upload
selectedTabIndex == 0 -> {
GeneralUploadTab(
modifier = Modifier.padding(paddingValues),
viewModel = viewModel
)
}
// Samsung device with tab 1 selected: dumpstate
else -> {
DumpstateTab(
modifier = Modifier.padding(paddingValues),
viewModel = viewModel
)
}
}
}
}

View File

@@ -0,0 +1,258 @@
package com.mattintech.simplelogupload.ui.screens
import android.Manifest
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.QrCodeScanner
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.journeyapps.barcodescanner.ScanContract
import com.mattintech.simplelogupload.R
import com.mattintech.simplelogupload.model.QrServerConfig
import com.mattintech.simplelogupload.util.PreferencesUtil
import com.mattintech.simplelogupload.util.QrScannerUtil
/**
* Server setup screen for configuring server connection
*
* @param onSetupComplete Callback when setup is successfully completed
* @param onNavigateBack Callback when back button is pressed
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ServerSetupScreen(
onSetupComplete: () -> Unit,
onNavigateBack: () -> Unit
) {
val context = LocalContext.current
var serverAddress by remember { mutableStateOf("") }
var serverPort by remember { mutableStateOf("") }
var clientKey by remember { mutableStateOf("") }
var serverAddressError by remember { mutableStateOf<String?>(null) }
var serverPortError by remember { mutableStateOf<String?>(null) }
var clientKeyError by remember { mutableStateOf<String?>(null) }
// QR scanner launcher
val qrScannerLauncher = rememberLauncherForActivityResult(
contract = ScanContract()
) { result ->
QrScannerUtil.processScanResult(
result.contents,
object : QrScannerUtil.QrScanCallback {
override fun onQrScanned(config: QrServerConfig) {
serverAddress = config.server
serverPort = config.port.toString()
clientKey = config.key
Toast.makeText(
context,
R.string.qr_scan_success,
Toast.LENGTH_SHORT
).show()
}
override fun onQrScanCancelled() {
Toast.makeText(
context,
R.string.qr_scan_cancelled,
Toast.LENGTH_SHORT
).show()
}
override fun onQrScanError(errorMessage: String) {
Toast.makeText(
context,
context.getString(R.string.qr_scan_error, errorMessage),
Toast.LENGTH_LONG
).show()
}
}
)
}
// Camera permission launcher
val cameraPermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { isGranted ->
if (isGranted) {
val options = QrScannerUtil.createScanOptions()
qrScannerLauncher.launch(options)
} else {
Toast.makeText(
context,
R.string.camera_permission_denied,
Toast.LENGTH_LONG
).show()
}
}
fun validateAndSave() {
// Clear previous errors
serverAddressError = null
serverPortError = null
clientKeyError = null
var hasError = false
// Validate server address
if (serverAddress.isBlank()) {
serverAddressError = "Server address is required"
hasError = true
}
// Validate server port
if (serverPort.isBlank()) {
serverPortError = "Server port is required"
hasError = true
} else {
val port = serverPort.toIntOrNull()
if (port == null || port <= 0 || port > 65535) {
serverPortError = "Port must be between 1 and 65535"
hasError = true
}
}
// Validate client key
if (clientKey.isBlank()) {
clientKeyError = "Client key is required"
hasError = true
}
if (hasError) return
// Save settings
PreferencesUtil.setServerAddress(context, serverAddress)
PreferencesUtil.setServerPort(context, serverPort.toInt())
PreferencesUtil.setClientKey(context, clientKey)
PreferencesUtil.setSetupCompleted(context, true)
Toast.makeText(context, "Setup completed successfully!", Toast.LENGTH_SHORT).show()
onSetupComplete()
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Server Setup") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Navigate back")
}
}
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(24.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Instructions card
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) {
Text(
text = "Enter your server connection details below, or scan a QR code from your server admin panel to auto-fill.",
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
Spacer(modifier = Modifier.height(8.dp))
// Server Address
OutlinedTextField(
value = serverAddress,
onValueChange = {
serverAddress = it
serverAddressError = null
},
label = { Text("Server Address") },
placeholder = { Text("e.g., 192.168.1.100 or example.com") },
modifier = Modifier.fillMaxWidth(),
isError = serverAddressError != null,
supportingText = serverAddressError?.let { { Text(it) } },
singleLine = true
)
// Server Port
OutlinedTextField(
value = serverPort,
onValueChange = {
serverPort = it
serverPortError = null
},
label = { Text("Server Port") },
placeholder = { Text("e.g., 8080") },
modifier = Modifier.fillMaxWidth(),
isError = serverPortError != null,
supportingText = serverPortError?.let { { Text(it) } },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true
)
// Client Key
OutlinedTextField(
value = clientKey,
onValueChange = {
clientKey = it
clientKeyError = null
},
label = { Text("Client Key") },
placeholder = { Text("Authentication key") },
modifier = Modifier.fillMaxWidth(),
isError = clientKeyError != null,
supportingText = clientKeyError?.let { { Text(it) } },
singleLine = true
)
Spacer(modifier = Modifier.height(8.dp))
// QR Scan Button
OutlinedButton(
onClick = {
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
},
modifier = Modifier.fillMaxWidth()
) {
Icon(
Icons.Default.QrCodeScanner,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Scan QR Code")
}
Spacer(modifier = Modifier.height(16.dp))
// Finish Setup Button
Button(
onClick = { validateAndSave() },
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Finish Setup",
modifier = Modifier.padding(vertical = 8.dp)
)
}
}
}
}

View File

@@ -0,0 +1,280 @@
package com.mattintech.simplelogupload.ui.screens
import android.Manifest
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.QrCodeScanner
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.journeyapps.barcodescanner.ScanContract
import com.mattintech.simplelogupload.R
import com.mattintech.simplelogupload.model.QrServerConfig
import com.mattintech.simplelogupload.util.PreferencesUtil
import com.mattintech.simplelogupload.util.QrScannerUtil
/**
* Settings screen for modifying server configuration
*
* @param onNavigateBack Callback when back button is pressed
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
onNavigateBack: () -> Unit
) {
val context = LocalContext.current
// Load existing settings
var serverAddress by remember {
mutableStateOf(PreferencesUtil.getServerAddress(context))
}
var serverPort by remember {
mutableStateOf(PreferencesUtil.getServerPort(context).toString())
}
var clientKey by remember {
mutableStateOf(PreferencesUtil.getClientKey(context))
}
var serverAddressError by remember { mutableStateOf<String?>(null) }
var serverPortError by remember { mutableStateOf<String?>(null) }
var clientKeyError by remember { mutableStateOf<String?>(null) }
// QR scanner launcher
val qrScannerLauncher = rememberLauncherForActivityResult(
contract = ScanContract()
) { result ->
QrScannerUtil.processScanResult(
result.contents,
object : QrScannerUtil.QrScanCallback {
override fun onQrScanned(config: QrServerConfig) {
serverAddress = config.server
serverPort = config.port.toString()
clientKey = config.key
Toast.makeText(
context,
R.string.qr_scan_success,
Toast.LENGTH_SHORT
).show()
}
override fun onQrScanCancelled() {
Toast.makeText(
context,
R.string.qr_scan_cancelled,
Toast.LENGTH_SHORT
).show()
}
override fun onQrScanError(errorMessage: String) {
Toast.makeText(
context,
context.getString(R.string.qr_scan_error, errorMessage),
Toast.LENGTH_LONG
).show()
}
}
)
}
// Camera permission launcher
val cameraPermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { isGranted ->
if (isGranted) {
val options = QrScannerUtil.createScanOptions()
qrScannerLauncher.launch(options)
} else {
Toast.makeText(
context,
R.string.camera_permission_denied,
Toast.LENGTH_LONG
).show()
}
}
fun validateAndSave() {
// Clear previous errors
serverAddressError = null
serverPortError = null
clientKeyError = null
var hasError = false
// Validate server address
if (serverAddress.isBlank()) {
serverAddressError = "Server address is required"
hasError = true
}
// Validate server port
if (serverPort.isBlank()) {
serverPortError = "Server port is required"
hasError = true
} else {
val port = serverPort.toIntOrNull()
if (port == null || port <= 0 || port > 65535) {
serverPortError = "Port must be between 1 and 65535"
hasError = true
}
}
// Validate client key
if (clientKey.isBlank()) {
clientKeyError = "Client key is required"
hasError = true
}
if (hasError) return
// Save settings
PreferencesUtil.setServerAddress(context, serverAddress)
PreferencesUtil.setServerPort(context, serverPort.toInt())
PreferencesUtil.setClientKey(context, clientKey)
Toast.makeText(context, "Settings saved successfully!", Toast.LENGTH_SHORT).show()
onNavigateBack()
}
fun resetSettings() {
// Reload original values
serverAddress = PreferencesUtil.getServerAddress(context)
serverPort = PreferencesUtil.getServerPort(context).toString()
clientKey = PreferencesUtil.getClientKey(context)
// Clear errors
serverAddressError = null
serverPortError = null
clientKeyError = null
Toast.makeText(context, "Settings reset", Toast.LENGTH_SHORT).show()
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Settings") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Navigate back")
}
}
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(24.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "Server Configuration",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(8.dp))
// Server Address
OutlinedTextField(
value = serverAddress,
onValueChange = {
serverAddress = it
serverAddressError = null
},
label = { Text("Server Address") },
placeholder = { Text("e.g., 192.168.1.100 or example.com") },
modifier = Modifier.fillMaxWidth(),
isError = serverAddressError != null,
supportingText = serverAddressError?.let { { Text(it) } },
singleLine = true
)
// Server Port
OutlinedTextField(
value = serverPort,
onValueChange = {
serverPort = it
serverPortError = null
},
label = { Text("Server Port") },
placeholder = { Text("e.g., 8080") },
modifier = Modifier.fillMaxWidth(),
isError = serverPortError != null,
supportingText = serverPortError?.let { { Text(it) } },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true
)
// Client Key
OutlinedTextField(
value = clientKey,
onValueChange = {
clientKey = it
clientKeyError = null
},
label = { Text("Client Key") },
placeholder = { Text("Authentication key") },
modifier = Modifier.fillMaxWidth(),
isError = clientKeyError != null,
supportingText = clientKeyError?.let { { Text(it) } },
singleLine = true
)
Spacer(modifier = Modifier.height(8.dp))
// QR Scan Button
OutlinedButton(
onClick = {
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
},
modifier = Modifier.fillMaxWidth()
) {
Icon(
Icons.Default.QrCodeScanner,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Scan QR Code")
}
Spacer(modifier = Modifier.height(16.dp))
// Action buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// Reset Button
OutlinedButton(
onClick = { resetSettings() },
modifier = Modifier.weight(1f)
) {
Text("Reset")
}
// Save Button
Button(
onClick = { validateAndSave() },
modifier = Modifier.weight(1f)
) {
Text("Save")
}
}
}
}
}

View File

@@ -0,0 +1,228 @@
package com.mattintech.simplelogupload.ui.screens
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.widget.Toast
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.mattintech.simplelogupload.model.UploadHistory
import com.mattintech.simplelogupload.viewmodel.UploadHistoryViewModel
import java.text.SimpleDateFormat
import java.util.*
/**
* Upload history screen showing past uploads
*
* @param onNavigateBack Callback when back button is pressed
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun UploadHistoryScreen(
onNavigateBack: () -> Unit,
viewModel: UploadHistoryViewModel = viewModel()
) {
val context = LocalContext.current
val history by viewModel.allHistory.observeAsState(emptyList())
val deleteStatus by viewModel.deleteStatus.observeAsState()
// Show toast on delete status
LaunchedEffect(deleteStatus) {
deleteStatus?.let { success ->
val message = if (success) {
"Upload deleted successfully"
} else {
"Failed to delete upload"
}
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Upload History") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Navigate back")
}
}
)
}
) { paddingValues ->
if (history.isEmpty()) {
// Empty state
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "No upload history",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "Your uploaded files will appear here",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
} else {
// History list
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(history, key = { it.id.toString() }) { item ->
UploadHistoryItem(
history = item,
onCopyCode = { code ->
copyToClipboard(context, code)
Toast.makeText(context, "Code copied", Toast.LENGTH_SHORT).show()
},
onDelete = { uploadHistory ->
viewModel.delete(uploadHistory)
}
)
}
}
}
}
}
/**
* Individual upload history item
*/
@Composable
private fun UploadHistoryItem(
history: UploadHistory,
onCopyCode: (String) -> Unit,
onDelete: (UploadHistory) -> Unit
) {
var showDeleteDialog by remember { mutableStateOf(false) }
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
// Filename
Text(
text = history.fileName,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(8.dp))
// Upload code
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Code: ${history.uploadCode}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
Text(
text = formatDate(history.uploadTime),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Row {
// Copy button
IconButton(onClick = { onCopyCode(history.uploadCode) }) {
Icon(
Icons.Default.ContentCopy,
contentDescription = "Copy code",
tint = MaterialTheme.colorScheme.primary
)
}
// Delete button
IconButton(onClick = { showDeleteDialog = true }) {
Icon(
Icons.Default.Delete,
contentDescription = "Delete",
tint = MaterialTheme.colorScheme.error
)
}
}
}
}
}
// Delete confirmation dialog
if (showDeleteDialog) {
AlertDialog(
onDismissRequest = { showDeleteDialog = false },
title = { Text("Delete Upload") },
text = { Text("Are you sure you want to delete this upload? This will also remove the file from the server.") },
confirmButton = {
TextButton(
onClick = {
onDelete(history)
showDeleteDialog = false
}
) {
Text("Delete", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { showDeleteDialog = false }) {
Text("Cancel")
}
}
)
}
}
/**
* Format timestamp to readable date
*/
private fun formatDate(timestamp: Long): String {
val sdf = SimpleDateFormat("MMM dd, yyyy hh:mm a", Locale.getDefault())
return sdf.format(Date(timestamp))
}
/**
* Copy text to clipboard
*/
private fun copyToClipboard(context: Context, text: String) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("Upload Code", text)
clipboard.setPrimaryClip(clip)
}

View File

@@ -0,0 +1,161 @@
package com.mattintech.simplelogupload.ui.screens
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.mattintech.simplelogupload.R
/**
* Welcome screen shown on first launch
* Guides users to server setup
*
* @param onNavigateToSetup Callback when user clicks continue button
*/
@Composable
fun WelcomeScreen(
onNavigateToSetup: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceBetween
) {
// Top section with logo and title
Column(
modifier = Modifier.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top
) {
Spacer(modifier = Modifier.height(32.dp))
// App logo
Image(
painter = painterResource(id = R.drawable.ic_app_icon),
contentDescription = "SimpleLogUpload logo",
modifier = Modifier.size(120.dp)
)
Spacer(modifier = Modifier.height(24.dp))
// Welcome title
Text(
text = "Welcome to SimpleLogUpload",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(24.dp))
// Description
Text(
text = "Before you can use this app, you need to configure it to connect to your SimpleLogUpload server instance.",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
// Requirements card
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = "The app requires:",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(12.dp))
RequirementItem(
title = "Server Address",
description = "IP or hostname of your server"
)
RequirementItem(
title = "Server Port",
description = "Port your server is running on"
)
RequirementItem(
title = "Client Key",
description = "Authentication key for secure uploads"
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Your administrator should have provided these details.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic
)
}
}
}
// Bottom button
Button(
onClick = onNavigateToSetup,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp)
) {
Text(
text = "Continue to Setup",
modifier = Modifier.padding(vertical = 8.dp)
)
}
}
}
/**
* Individual requirement item with bullet point
*/
@Composable
private fun RequirementItem(
title: String,
description: String
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.Start
) {
Text(
text = "",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(end = 4.dp)
)
Column {
Text(
text = title,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold
)
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}

View File

@@ -0,0 +1,252 @@
package com.mattintech.simplelogupload.ui.screens.tabs
import android.widget.Toast
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.mattintech.simplelogupload.ui.components.UploadParamsDialog
import com.mattintech.simplelogupload.ui.components.UploadResultDialog
import com.mattintech.simplelogupload.viewmodel.MainViewModel
import java.text.SimpleDateFormat
import java.util.*
/**
* Dumpstate upload tab for Samsung devices
* Shows detected dumpstate files with multi-select
*
* @param modifier Modifier for the composable
* @param viewModel MainViewModel instance
*/
@Composable
fun DumpstateTab(
modifier: Modifier = Modifier,
viewModel: MainViewModel
) {
val context = LocalContext.current
val uiState by viewModel.uiState.collectAsState()
// Show error toast
LaunchedEffect(uiState.errorMessage) {
uiState.errorMessage?.let { error ->
Toast.makeText(context, error, Toast.LENGTH_LONG).show()
viewModel.clearError()
}
}
Column(
modifier = modifier
.fillMaxSize()
.padding(16.dp)
) {
Spacer(modifier = Modifier.height(16.dp))
// Header card
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Samsung Dumpstate Files",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = when {
uiState.dumpstateFiles.isEmpty() -> "No dumpstate files found on this device"
uiState.dumpstateFiles.size == 1 -> "1 dumpstate file found"
else -> "${uiState.dumpstateFiles.size} dumpstate files found"
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
Spacer(modifier = Modifier.height(16.dp))
when {
uiState.dumpstateFiles.isEmpty() -> {
// Empty state
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "No dumpstate files detected",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "Generate a dumpstate file from your device settings",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
Button(onClick = { viewModel.loadDumpstateFiles() }) {
Text("Refresh")
}
}
}
}
else -> {
// Selection controls
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
TextButton(onClick = { viewModel.selectAllDumpstates() }) {
Text("Select All")
}
TextButton(onClick = { viewModel.clearDumpstateSelections() }) {
Text("Clear All")
}
}
// File list
LazyColumn(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
itemsIndexed(uiState.dumpstateFiles) { index, file ->
DumpstateFileItem(
file = file,
isSelected = uiState.selectedDumpstateIndices.contains(index),
onToggle = { viewModel.toggleDumpstateSelection(index) }
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Upload button
Button(
onClick = { viewModel.showUploadParamsDialog() },
modifier = Modifier.fillMaxWidth(),
enabled = !uiState.isLoading && uiState.selectedDumpstateIndices.isNotEmpty()
) {
val count = uiState.selectedDumpstateIndices.size
Text(
text = if (count > 0) "Upload Selected Files ($count)" else "Select Files to Upload",
modifier = Modifier.padding(vertical = 8.dp)
)
}
// Loading indicator
if (uiState.isLoading) {
Spacer(modifier = Modifier.height(16.dp))
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
}
}
}
// Upload params dialog
if (uiState.showUploadParamsDialog) {
UploadParamsDialog(
onDismiss = { viewModel.hideUploadParamsDialog() },
onConfirm = { maxDownloads, expiryHours ->
viewModel.uploadSelectedDumpstates(maxDownloads, expiryHours)
}
)
}
// Upload result dialog
if (uiState.showUploadResultDialog) {
uiState.uploadResult?.let { result ->
UploadResultDialog(
uploadCode = result.code,
onDismiss = { viewModel.hideUploadResultDialog() }
)
}
}
}
/**
* Individual dumpstate file item with checkbox
*/
@Composable
private fun DumpstateFileItem(
file: java.io.File,
isSelected: Boolean,
onToggle: () -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = if (isSelected) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surface
}
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = isSelected,
onCheckedChange = { onToggle() }
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = file.name,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Size: ${formatFileSize(file.length())}${formatDate(file.lastModified())}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
/**
* Format file size to human-readable string
*/
private fun formatFileSize(bytes: Long): String {
return when {
bytes < 1024 -> "$bytes B"
bytes < 1024 * 1024 -> "${bytes / 1024} KB"
bytes < 1024 * 1024 * 1024 -> "${bytes / (1024 * 1024)} MB"
else -> "${bytes / (1024 * 1024 * 1024)} GB"
}
}
/**
* Format timestamp to readable date
*/
private fun formatDate(timestamp: Long): String {
val sdf = SimpleDateFormat("MMM dd, yyyy", Locale.getDefault())
return sdf.format(Date(timestamp))
}

View File

@@ -0,0 +1,188 @@
package com.mattintech.simplelogupload.ui.screens.tabs
import android.Manifest
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Upload
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.mattintech.simplelogupload.ui.components.UploadParamsDialog
import com.mattintech.simplelogupload.ui.components.UploadResultDialog
import com.mattintech.simplelogupload.viewmodel.MainViewModel
/**
* General file upload tab
* Allows uploading any file from device storage
*
* @param modifier Modifier for the composable
* @param viewModel MainViewModel instance
*/
@Composable
fun GeneralUploadTab(
modifier: Modifier = Modifier,
viewModel: MainViewModel
) {
val context = LocalContext.current
val uiState by viewModel.uiState.collectAsState()
// File picker launcher
val filePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent()
) { uri ->
uri?.let {
viewModel.handleFileSelection(it)
}
}
// Storage permission launcher
val storagePermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { isGranted ->
if (isGranted) {
filePickerLauncher.launch("*/*")
} else {
Toast.makeText(
context,
"Storage permission is required to select files",
Toast.LENGTH_LONG
).show()
}
}
// Show error toast
LaunchedEffect(uiState.errorMessage) {
uiState.errorMessage?.let { error ->
Toast.makeText(context, error, Toast.LENGTH_LONG).show()
viewModel.clearError()
}
}
Column(
modifier = modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.Top,
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(16.dp))
// Instructions card
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Upload Files",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Select a file from your device to upload to your server. You'll receive a code to share for downloading the file.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
Spacer(modifier = Modifier.height(24.dp))
// Select file button
Button(
onClick = {
storagePermissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE)
},
modifier = Modifier.fillMaxWidth(),
enabled = !uiState.isLoading
) {
Icon(
Icons.Default.Upload,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Select File from Storage",
modifier = Modifier.padding(vertical = 8.dp)
)
}
// Selected file info
uiState.selectedFileName?.let { fileName ->
Spacer(modifier = Modifier.height(16.dp))
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Selected File",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = fileName,
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = { viewModel.showUploadParamsDialog() },
modifier = Modifier.fillMaxWidth(),
enabled = !uiState.isLoading
) {
Text("Upload File")
}
}
}
}
// Loading indicator
if (uiState.isLoading) {
Spacer(modifier = Modifier.height(24.dp))
CircularProgressIndicator()
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Uploading...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
// Upload params dialog
if (uiState.showUploadParamsDialog) {
UploadParamsDialog(
onDismiss = { viewModel.hideUploadParamsDialog() },
onConfirm = { maxDownloads, expiryHours ->
uiState.selectedFile?.let { file ->
viewModel.uploadFile(file, maxDownloads, expiryHours)
}
}
)
}
// Upload result dialog
if (uiState.showUploadResultDialog) {
uiState.uploadResult?.let { result ->
UploadResultDialog(
uploadCode = result.code,
onDismiss = { viewModel.hideUploadResultDialog() }
)
}
}
}

View File

@@ -0,0 +1,26 @@
package com.mattintech.simplelogupload.ui.theme
import androidx.compose.ui.graphics.Color
// Google Blue Primary (matching app icon)
val GoogleBlue = Color(0xFF4285F4)
val GoogleBlueDark = Color(0xFF1A73E8)
val GoogleBlueLight = Color(0xFF669DF6)
// Google Green Accent (matching app icon)
val GoogleGreen = Color(0xFF34A853)
val GoogleGreenDark = Color(0xFF0F9D58)
val GoogleGreenLight = Color(0xFF57BB8A)
// Neutral colors
val White = Color(0xFFFFFFFF)
val Black = Color(0xFF000000)
val Gray50 = Color(0xFFF8F9FA)
val Gray100 = Color(0xFFF1F3F4)
val Gray200 = Color(0xFFE8EAED)
val Gray700 = Color(0xFF5F6368)
val Gray900 = Color(0xFF202124)
// System colors
val ErrorRed = Color(0xFFB00020)
val ErrorRedLight = Color(0xFFCF6679)

View File

@@ -0,0 +1,89 @@
package com.mattintech.simplelogupload.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
/**
* Light color scheme using Google Blue and Green from app icon
*/
private val LightColorScheme = lightColorScheme(
primary = GoogleBlue,
onPrimary = White,
primaryContainer = GoogleBlueLight,
onPrimaryContainer = Gray900,
secondary = GoogleGreen,
onSecondary = White,
secondaryContainer = GoogleGreenLight,
onSecondaryContainer = Gray900,
tertiary = GoogleBlueDark,
onTertiary = White,
tertiaryContainer = GoogleBlueLight,
onTertiaryContainer = Gray900,
error = ErrorRed,
onError = White,
errorContainer = ErrorRedLight,
onErrorContainer = Gray900,
background = White,
onBackground = Gray900,
surface = White,
onSurface = Gray900,
surfaceVariant = Gray100,
onSurfaceVariant = Gray700,
outline = Gray200,
outlineVariant = Gray100,
scrim = Black
)
/**
* Dark color scheme using Google Blue and Green from app icon
*/
private val DarkColorScheme = darkColorScheme(
primary = GoogleBlueLight,
onPrimary = Gray900,
primaryContainer = GoogleBlueDark,
onPrimaryContainer = Gray100,
secondary = GoogleGreen,
onSecondary = Gray900,
secondaryContainer = GoogleGreenDark,
onSecondaryContainer = Gray100,
tertiary = GoogleBlue,
onTertiary = Gray900,
tertiaryContainer = GoogleBlueDark,
onTertiaryContainer = Gray100,
error = ErrorRedLight,
onError = Gray900,
errorContainer = ErrorRed,
onErrorContainer = Gray100,
background = Gray900,
onBackground = Gray100,
surface = Gray900,
onSurface = Gray100,
surfaceVariant = Gray700,
onSurfaceVariant = Gray200,
outline = Gray700,
outlineVariant = Gray700,
scrim = Black
)
/**
* SimpleLogUpload theme with Google Blue/Green branding
*
* @param darkTheme Whether to use dark theme (follows system by default)
* @param content Composable content to apply theme to
*/
@Composable
fun SimpleLogUploadTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}

View File

@@ -0,0 +1,117 @@
package com.mattintech.simplelogupload.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Material 3 default typography works well
// Can be customized if specific fonts are needed
val Typography = Typography(
displayLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 57.sp,
lineHeight = 64.sp,
letterSpacing = (-0.25).sp
),
displayMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 45.sp,
lineHeight = 52.sp,
letterSpacing = 0.sp
),
displaySmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 36.sp,
lineHeight = 44.sp,
letterSpacing = 0.sp
),
headlineLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 32.sp,
lineHeight = 40.sp,
letterSpacing = 0.sp
),
headlineMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 28.sp,
lineHeight = 36.sp,
letterSpacing = 0.sp
),
headlineSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 24.sp,
lineHeight = 32.sp,
letterSpacing = 0.sp
),
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
titleMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.15.sp
),
titleSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp
),
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
),
bodyMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.25.sp
),
bodySmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.4.sp
),
labelLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp
),
labelMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
)

View File

@@ -0,0 +1,45 @@
package com.mattintech.simplelogupload.util
import android.os.Build
/**
* Utility object for device-specific feature detection
*/
object DeviceUtil {
/**
* Check if the current device is a Samsung device
*
* @return true if device manufacturer is Samsung, false otherwise
*/
fun isSamsungDevice(): Boolean {
return Build.MANUFACTURER.equals("samsung", ignoreCase = true)
}
/**
* Check if dumpstate features should be shown on this device
* Currently only Samsung devices have dumpstate file support
*
* @return true if dumpstate features should be visible
*/
fun shouldShowDumpstateFeatures(): Boolean {
return isSamsungDevice()
}
/**
* Get device manufacturer name
*
* @return Device manufacturer (e.g., "Samsung", "Google", "Xiaomi")
*/
fun getManufacturer(): String {
return Build.MANUFACTURER
}
/**
* Get device model name
*
* @return Device model (e.g., "SM-G991U", "Pixel 7")
*/
fun getModel(): String {
return Build.MODEL
}
}

View File

@@ -0,0 +1,323 @@
package com.mattintech.simplelogupload.viewmodel
import android.app.Application
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.Uri
import android.util.Log
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import com.mattintech.simplelogupload.constant.AppConstants
import com.mattintech.simplelogupload.controller.FileController
import com.mattintech.simplelogupload.controller.UploadController
import com.mattintech.simplelogupload.model.UploadResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.io.File
/**
* UI state for the main screen
*/
data class MainUiState(
val isLoading: Boolean = false,
val selectedFile: File? = null,
val selectedFileName: String? = null,
val dumpstateFiles: List<File> = emptyList(),
val selectedDumpstateIndices: Set<Int> = emptySet(),
val uploadResult: UploadResult? = null,
val errorMessage: String? = null,
val showUploadParamsDialog: Boolean = false,
val showUploadResultDialog: Boolean = false
)
/**
* ViewModel for the main screen with upload functionality
*/
class MainViewModel(application: Application) : AndroidViewModel(application) {
private val TAG = AppConstants.APP_TAG + "MainViewModel"
private val uploadController = UploadController(application)
private val _uiState = MutableStateFlow(MainUiState())
val uiState: StateFlow<MainUiState> = _uiState.asStateFlow()
private var uploadReceiver: BroadcastReceiver? = null
init {
loadDumpstateFiles()
registerUploadReceiver()
}
/**
* Register broadcast receiver for upload completion
*/
private fun registerUploadReceiver() {
uploadReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
AppConstants.ACTION_UPLOAD_COMPLETE -> {
val code = intent.getStringExtra(AppConstants.EXTRA_UPLOAD_CODE)
Log.d(TAG, "Upload completed: $code")
_uiState.value = _uiState.value.copy(
isLoading = false,
uploadResult = UploadResult(code ?: "UNKNOWN"),
showUploadResultDialog = true
)
}
AppConstants.ACTION_UPLOAD_ERROR -> {
val error = intent.getStringExtra(AppConstants.EXTRA_UPLOAD_ERROR)
Log.e(TAG, "Upload error: $error")
_uiState.value = _uiState.value.copy(
isLoading = false,
errorMessage = error ?: "Unknown error"
)
}
}
}
}
val filter = IntentFilter().apply {
addAction(AppConstants.ACTION_UPLOAD_COMPLETE)
addAction(AppConstants.ACTION_UPLOAD_ERROR)
}
LocalBroadcastManager.getInstance(getApplication())
.registerReceiver(uploadReceiver!!, filter)
}
/**
* Load all dumpstate files from device
*/
fun loadDumpstateFiles() {
viewModelScope.launch(Dispatchers.IO) {
try {
val files = FileController.getAllDumpStateFiles()
_uiState.value = _uiState.value.copy(
dumpstateFiles = files.toList()
)
Log.d(TAG, "Loaded ${files.size} dumpstate files")
} catch (e: Exception) {
Log.e(TAG, "Error loading dumpstate files", e)
_uiState.value = _uiState.value.copy(
errorMessage = "Error loading dumpstate files: ${e.message}"
)
}
}
}
/**
* Handle file selection from file picker
*/
fun handleFileSelection(uri: Uri) {
viewModelScope.launch(Dispatchers.IO) {
try {
// TODO: Convert URI to File using FileController
// For now, store URI info
val fileName = uri.lastPathSegment ?: "Unknown file"
_uiState.value = _uiState.value.copy(
selectedFileName = fileName,
selectedFile = null // Will need to implement URI to File conversion
)
Log.d(TAG, "File selected: $fileName")
} catch (e: Exception) {
Log.e(TAG, "Error handling file selection", e)
_uiState.value = _uiState.value.copy(
errorMessage = "Error selecting file: ${e.message}"
)
}
}
}
/**
* Toggle dumpstate file selection
*/
fun toggleDumpstateSelection(index: Int) {
val currentSelections = _uiState.value.selectedDumpstateIndices.toMutableSet()
if (currentSelections.contains(index)) {
currentSelections.remove(index)
} else {
currentSelections.add(index)
}
_uiState.value = _uiState.value.copy(
selectedDumpstateIndices = currentSelections
)
}
/**
* Select all dumpstate files
*/
fun selectAllDumpstates() {
val allIndices = _uiState.value.dumpstateFiles.indices.toSet()
_uiState.value = _uiState.value.copy(
selectedDumpstateIndices = allIndices
)
}
/**
* Clear all dumpstate selections
*/
fun clearDumpstateSelections() {
_uiState.value = _uiState.value.copy(
selectedDumpstateIndices = emptySet()
)
}
/**
* Show upload parameters dialog
*/
fun showUploadParamsDialog() {
_uiState.value = _uiState.value.copy(
showUploadParamsDialog = true
)
}
/**
* Hide upload parameters dialog
*/
fun hideUploadParamsDialog() {
_uiState.value = _uiState.value.copy(
showUploadParamsDialog = false
)
}
/**
* Hide upload result dialog
*/
fun hideUploadResultDialog() {
_uiState.value = _uiState.value.copy(
showUploadResultDialog = false,
uploadResult = null
)
}
/**
* Clear error message
*/
fun clearError() {
_uiState.value = _uiState.value.copy(
errorMessage = null
)
}
/**
* Upload a general file
*/
fun uploadFile(file: File, maxDownloads: Int, expiryHours: Int) {
viewModelScope.launch(Dispatchers.IO) {
try {
_uiState.value = _uiState.value.copy(
isLoading = true,
showUploadParamsDialog = false
)
Log.d(TAG, "Starting upload for file: ${file.name}")
// Upload file and handle result
uploadController.uploadFile(file, maxDownloads, expiryHours)
.thenAccept { result ->
if (result.isSuccess) {
_uiState.value = _uiState.value.copy(
isLoading = false,
uploadResult = result,
showUploadResultDialog = true
)
} else {
_uiState.value = _uiState.value.copy(
isLoading = false,
errorMessage = result.error ?: "Upload failed"
)
}
}
.exceptionally { ex ->
_uiState.value = _uiState.value.copy(
isLoading = false,
errorMessage = "Error uploading file: ${ex.message}"
)
null
}
} catch (e: Exception) {
Log.e(TAG, "Error uploading file", e)
_uiState.value = _uiState.value.copy(
isLoading = false,
errorMessage = "Error uploading file: ${e.message}"
)
}
}
}
/**
* Upload selected dumpstate files
* Uploads multiple files by combining them into a ZIP first
*/
fun uploadSelectedDumpstates(maxDownloads: Int, expiryHours: Int) {
viewModelScope.launch(Dispatchers.IO) {
try {
val files = _uiState.value.selectedDumpstateIndices
.map { _uiState.value.dumpstateFiles[it] }
if (files.isEmpty()) {
_uiState.value = _uiState.value.copy(
errorMessage = "No dumpstate files selected"
)
return@launch
}
_uiState.value = _uiState.value.copy(
isLoading = true,
showUploadParamsDialog = false
)
Log.d(TAG, "Starting upload for ${files.size} dumpstate files")
// For now, upload the first file
// TODO: Implement proper multi-file ZIP and upload
val firstFile = files.first()
uploadController.uploadFile(firstFile, maxDownloads, expiryHours)
.thenAccept { result ->
if (result.isSuccess) {
_uiState.value = _uiState.value.copy(
isLoading = false,
uploadResult = result,
showUploadResultDialog = true,
selectedDumpstateIndices = emptySet()
)
} else {
_uiState.value = _uiState.value.copy(
isLoading = false,
errorMessage = result.error ?: "Upload failed"
)
}
}
.exceptionally { ex ->
_uiState.value = _uiState.value.copy(
isLoading = false,
errorMessage = "Error uploading files: ${ex.message}"
)
null
}
} catch (e: Exception) {
Log.e(TAG, "Error uploading dumpstate files", e)
_uiState.value = _uiState.value.copy(
isLoading = false,
errorMessage = "Error uploading files: ${e.message}"
)
}
}
}
override fun onCleared() {
super.onCleared()
// Unregister broadcast receiver
uploadReceiver?.let {
LocalBroadcastManager.getInstance(getApplication()).unregisterReceiver(it)
}
}
}

View File

@@ -1,7 +1,7 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
ext.kotlin_version = '1.8.0' // Updated to be compatible with Gradle 8.x
ext.kotlin_version = '1.9.22' // Updated for Compose Compiler 1.5.8 compatibility
repositories {
google()
mavenCentral()