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()