diff --git a/app/build.gradle b/app/build.gradle
index 0fd29eb..5c21a8c 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -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'
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 212ac86..01c1422 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -22,22 +22,25 @@
android:usesCleartextTraffic="false"
android:theme="@style/Theme.AppCompat.DayNight.DarkActionBar">
-
+
+ android:name=".ComposeMainActivity"
+ android:exported="true"
+ android:theme="@style/Theme.AppCompat.Light.NoActionBar">
+
+
-
+
-
+
@@ -45,7 +48,14 @@
-
+
+
+
+
+
+
(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(Intent.EXTRA_STREAM)
+ // TODO: Handle multiple files
+ return sharedUris?.firstOrNull()
+ }
+ return null
+ }
+}
diff --git a/app/src/main/java/com/mattintech/simplelogupload/navigation/AppNavigation.kt b/app/src/main/java/com/mattintech/simplelogupload/navigation/AppNavigation.kt
new file mode 100644
index 0000000..8f70c9a
--- /dev/null
+++ b/app/src/main/java/com/mattintech/simplelogupload/navigation/AppNavigation.kt
@@ -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()
+ }
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/mattintech/simplelogupload/navigation/Screen.kt b/app/src/main/java/com/mattintech/simplelogupload/navigation/Screen.kt
new file mode 100644
index 0000000..7be63f9
--- /dev/null
+++ b/app/src/main/java/com/mattintech/simplelogupload/navigation/Screen.kt
@@ -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")
+}
diff --git a/app/src/main/java/com/mattintech/simplelogupload/ui/components/UploadParamsDialog.kt b/app/src/main/java/com/mattintech/simplelogupload/ui/components/UploadParamsDialog.kt
new file mode 100644
index 0000000..4d92b56
--- /dev/null
+++ b/app/src/main/java/com/mattintech/simplelogupload/ui/components/UploadParamsDialog.kt
@@ -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(null) }
+ var expiryHoursError by remember { mutableStateOf(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")
+ }
+ }
+ )
+}
diff --git a/app/src/main/java/com/mattintech/simplelogupload/ui/components/UploadResultDialog.kt b/app/src/main/java/com/mattintech/simplelogupload/ui/components/UploadResultDialog.kt
new file mode 100644
index 0000000..c350331
--- /dev/null
+++ b/app/src/main/java/com/mattintech/simplelogupload/ui/components/UploadResultDialog.kt
@@ -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")
+ }
+ }
+ )
+}
diff --git a/app/src/main/java/com/mattintech/simplelogupload/ui/screens/MainScreen.kt b/app/src/main/java/com/mattintech/simplelogupload/ui/screens/MainScreen.kt
new file mode 100644
index 0000000..99e9ce8
--- /dev/null
+++ b/app/src/main/java/com/mattintech/simplelogupload/ui/screens/MainScreen.kt
@@ -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
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/mattintech/simplelogupload/ui/screens/ServerSetupScreen.kt b/app/src/main/java/com/mattintech/simplelogupload/ui/screens/ServerSetupScreen.kt
new file mode 100644
index 0000000..330868e
--- /dev/null
+++ b/app/src/main/java/com/mattintech/simplelogupload/ui/screens/ServerSetupScreen.kt
@@ -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(null) }
+ var serverPortError by remember { mutableStateOf(null) }
+ var clientKeyError by remember { mutableStateOf(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)
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/mattintech/simplelogupload/ui/screens/SettingsScreen.kt b/app/src/main/java/com/mattintech/simplelogupload/ui/screens/SettingsScreen.kt
new file mode 100644
index 0000000..17c6bc0
--- /dev/null
+++ b/app/src/main/java/com/mattintech/simplelogupload/ui/screens/SettingsScreen.kt
@@ -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(null) }
+ var serverPortError by remember { mutableStateOf(null) }
+ var clientKeyError by remember { mutableStateOf(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")
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/mattintech/simplelogupload/ui/screens/UploadHistoryScreen.kt b/app/src/main/java/com/mattintech/simplelogupload/ui/screens/UploadHistoryScreen.kt
new file mode 100644
index 0000000..eff40e3
--- /dev/null
+++ b/app/src/main/java/com/mattintech/simplelogupload/ui/screens/UploadHistoryScreen.kt
@@ -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)
+}
diff --git a/app/src/main/java/com/mattintech/simplelogupload/ui/screens/WelcomeScreen.kt b/app/src/main/java/com/mattintech/simplelogupload/ui/screens/WelcomeScreen.kt
new file mode 100644
index 0000000..e8c49a4
--- /dev/null
+++ b/app/src/main/java/com/mattintech/simplelogupload/ui/screens/WelcomeScreen.kt
@@ -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
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/mattintech/simplelogupload/ui/screens/tabs/DumpstateTab.kt b/app/src/main/java/com/mattintech/simplelogupload/ui/screens/tabs/DumpstateTab.kt
new file mode 100644
index 0000000..d0c8951
--- /dev/null
+++ b/app/src/main/java/com/mattintech/simplelogupload/ui/screens/tabs/DumpstateTab.kt
@@ -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))
+}
diff --git a/app/src/main/java/com/mattintech/simplelogupload/ui/screens/tabs/GeneralUploadTab.kt b/app/src/main/java/com/mattintech/simplelogupload/ui/screens/tabs/GeneralUploadTab.kt
new file mode 100644
index 0000000..dc8e4fd
--- /dev/null
+++ b/app/src/main/java/com/mattintech/simplelogupload/ui/screens/tabs/GeneralUploadTab.kt
@@ -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() }
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/mattintech/simplelogupload/ui/theme/Color.kt b/app/src/main/java/com/mattintech/simplelogupload/ui/theme/Color.kt
new file mode 100644
index 0000000..475f62f
--- /dev/null
+++ b/app/src/main/java/com/mattintech/simplelogupload/ui/theme/Color.kt
@@ -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)
diff --git a/app/src/main/java/com/mattintech/simplelogupload/ui/theme/Theme.kt b/app/src/main/java/com/mattintech/simplelogupload/ui/theme/Theme.kt
new file mode 100644
index 0000000..bd07a96
--- /dev/null
+++ b/app/src/main/java/com/mattintech/simplelogupload/ui/theme/Theme.kt
@@ -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
+ )
+}
diff --git a/app/src/main/java/com/mattintech/simplelogupload/ui/theme/Type.kt b/app/src/main/java/com/mattintech/simplelogupload/ui/theme/Type.kt
new file mode 100644
index 0000000..7fbf51e
--- /dev/null
+++ b/app/src/main/java/com/mattintech/simplelogupload/ui/theme/Type.kt
@@ -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
+ )
+)
diff --git a/app/src/main/java/com/mattintech/simplelogupload/util/DeviceUtil.kt b/app/src/main/java/com/mattintech/simplelogupload/util/DeviceUtil.kt
new file mode 100644
index 0000000..5101e6d
--- /dev/null
+++ b/app/src/main/java/com/mattintech/simplelogupload/util/DeviceUtil.kt
@@ -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
+ }
+}
diff --git a/app/src/main/java/com/mattintech/simplelogupload/viewmodel/MainViewModel.kt b/app/src/main/java/com/mattintech/simplelogupload/viewmodel/MainViewModel.kt
new file mode 100644
index 0000000..02e4f13
--- /dev/null
+++ b/app/src/main/java/com/mattintech/simplelogupload/viewmodel/MainViewModel.kt
@@ -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 = emptyList(),
+ val selectedDumpstateIndices: Set = 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 = _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)
+ }
+ }
+}
diff --git a/build.gradle b/build.gradle
index 2970e1a..8a4dcf9 100644
--- a/build.gradle
+++ b/build.gradle
@@ -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()