Migrate UI to Jetpack Compose with Material 3 design
- Replace XML layouts with Compose screens for all 5 Activities - Implement Material 3 theme with Google Blue/Green color scheme matching app icon - Add device-aware navigation with bottom tabs for Samsung devices - Remove action bar for modern edge-to-edge UI design - Update Kotlin to 1.9.22 for Compose Compiler compatibility - Add Compose dependencies and configure build system - Implement MainViewModel for upload state management - Create reusable dialog components for upload parameters and results - Preserve all existing Java controllers and services (UI-only migration)
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -22,22 +22,25 @@
|
||||
android:usesCleartextTraffic="false"
|
||||
android:theme="@style/Theme.AppCompat.DayNight.DarkActionBar">
|
||||
|
||||
<!-- Main Activity -->
|
||||
<!-- NEW: Compose Main Activity (LAUNCHER) -->
|
||||
<activity
|
||||
android:name=".view.MainActivity"
|
||||
android:exported="true">
|
||||
android:name=".ComposeMainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.AppCompat.Light.NoActionBar">
|
||||
|
||||
<!-- LAUNCHER - App starts here -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
|
||||
<!-- Share intent filter for files -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="*/*" />
|
||||
</intent-filter>
|
||||
|
||||
|
||||
<!-- Share intent filter for multiple files -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||
@@ -45,7 +48,14 @@
|
||||
<data android:mimeType="*/*" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
|
||||
<!-- OLD: Legacy MainActivity (DEPRECATED - kept for reference only) -->
|
||||
<activity
|
||||
android:name=".view.MainActivity"
|
||||
android:exported="false">
|
||||
<!-- Removed LAUNCHER intent - now using ComposeMainActivity -->
|
||||
</activity>
|
||||
|
||||
<!-- Upload History Activity -->
|
||||
<activity
|
||||
android:name=".view.UploadHistoryActivity"
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.mattintech.simplelogupload
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.mattintech.simplelogupload.navigation.AppNavigation
|
||||
import com.mattintech.simplelogupload.navigation.Screen
|
||||
import com.mattintech.simplelogupload.ui.theme.SimpleLogUploadTheme
|
||||
import com.mattintech.simplelogupload.util.PreferencesUtil
|
||||
|
||||
/**
|
||||
* Main Activity for the Compose-based UI
|
||||
* Replaces all previous XML-based Activities with a single Compose Activity
|
||||
*/
|
||||
class ComposeMainActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// Enable edge-to-edge display for modern UI
|
||||
enableEdgeToEdge()
|
||||
|
||||
// Determine start destination based on setup status
|
||||
val startDestination = getStartDestination()
|
||||
|
||||
// Handle share intents if this was launched from external app
|
||||
val sharedFileUri = handleShareIntent(intent)
|
||||
|
||||
setContent {
|
||||
SimpleLogUploadTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
|
||||
AppNavigation(
|
||||
navController = navController,
|
||||
startDestination = startDestination
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
// Handle new share intents when activity is already running
|
||||
handleShareIntent(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine which screen to show first based on app configuration
|
||||
*/
|
||||
private fun getStartDestination(): String {
|
||||
return if (PreferencesUtil.isSetupCompleted(this) &&
|
||||
PreferencesUtil.areServerSettingsConfigured(this)) {
|
||||
Screen.Main.route
|
||||
} else {
|
||||
Screen.Welcome.route
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle share intents from external apps
|
||||
*
|
||||
* @param intent The intent that launched this activity
|
||||
* @return Uri of shared file, or null if not a share intent
|
||||
*/
|
||||
private fun handleShareIntent(intent: Intent?): Uri? {
|
||||
if (intent?.action == Intent.ACTION_SEND) {
|
||||
if (intent.type?.startsWith("text/") == true) {
|
||||
// Handle shared text
|
||||
val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT)
|
||||
// TODO: Handle shared text if needed
|
||||
return null
|
||||
} else {
|
||||
// Handle shared file
|
||||
val sharedUri = intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)
|
||||
// TODO: Pass this URI to MainScreen/MainViewModel for upload
|
||||
return sharedUri
|
||||
}
|
||||
} else if (intent?.action == Intent.ACTION_SEND_MULTIPLE) {
|
||||
// Handle multiple shared files
|
||||
val sharedUris = intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)
|
||||
// TODO: Handle multiple files
|
||||
return sharedUris?.firstOrNull()
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.mattintech.simplelogupload.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Dialog for entering upload parameters
|
||||
*
|
||||
* @param onDismiss Callback when dialog is dismissed
|
||||
* @param onConfirm Callback when upload is confirmed with (maxDownloads, expiryHours)
|
||||
*/
|
||||
@Composable
|
||||
fun UploadParamsDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (Int, Int) -> Unit
|
||||
) {
|
||||
var maxDownloads by remember { mutableStateOf("5") }
|
||||
var expiryHours by remember { mutableStateOf("24") }
|
||||
|
||||
var maxDownloadsError by remember { mutableStateOf<String?>(null) }
|
||||
var expiryHoursError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
fun validateAndConfirm() {
|
||||
maxDownloadsError = null
|
||||
expiryHoursError = null
|
||||
|
||||
var hasError = false
|
||||
|
||||
val maxDl = maxDownloads.toIntOrNull()
|
||||
if (maxDl == null || maxDl < 0) {
|
||||
maxDownloadsError = "Must be 0 or greater (0 = unlimited)"
|
||||
hasError = true
|
||||
}
|
||||
|
||||
val expiry = expiryHours.toIntOrNull()
|
||||
if (expiry == null || expiry <= 0 || expiry > 24) {
|
||||
expiryHoursError = "Must be between 1 and 24 hours"
|
||||
hasError = true
|
||||
}
|
||||
|
||||
if (!hasError) {
|
||||
onConfirm(maxDl!!, expiry!!)
|
||||
}
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Upload Parameters") },
|
||||
text = {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// Max Downloads
|
||||
OutlinedTextField(
|
||||
value = maxDownloads,
|
||||
onValueChange = {
|
||||
maxDownloads = it
|
||||
maxDownloadsError = null
|
||||
},
|
||||
label = { Text("Maximum Downloads") },
|
||||
placeholder = { Text("Default: 5") },
|
||||
supportingText = {
|
||||
Text(maxDownloadsError ?: "Set to 0 for unlimited downloads")
|
||||
},
|
||||
isError = maxDownloadsError != null,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
// Expiry Hours
|
||||
OutlinedTextField(
|
||||
value = expiryHours,
|
||||
onValueChange = {
|
||||
expiryHours = it
|
||||
expiryHoursError = null
|
||||
},
|
||||
label = { Text("Expiry Time (hours)") },
|
||||
placeholder = { Text("Default: 24") },
|
||||
supportingText = {
|
||||
Text(expiryHoursError ?: "Maximum 24 hours (1 day)")
|
||||
},
|
||||
isError = expiryHoursError != null,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = { validateAndConfirm() }) {
|
||||
Text("Upload")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package com.mattintech.simplelogupload.ui.screens
|
||||
|
||||
import android.Manifest
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.QrCodeScanner
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.journeyapps.barcodescanner.ScanContract
|
||||
import com.mattintech.simplelogupload.R
|
||||
import com.mattintech.simplelogupload.model.QrServerConfig
|
||||
import com.mattintech.simplelogupload.util.PreferencesUtil
|
||||
import com.mattintech.simplelogupload.util.QrScannerUtil
|
||||
|
||||
/**
|
||||
* Server setup screen for configuring server connection
|
||||
*
|
||||
* @param onSetupComplete Callback when setup is successfully completed
|
||||
* @param onNavigateBack Callback when back button is pressed
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ServerSetupScreen(
|
||||
onSetupComplete: () -> Unit,
|
||||
onNavigateBack: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var serverAddress by remember { mutableStateOf("") }
|
||||
var serverPort by remember { mutableStateOf("") }
|
||||
var clientKey by remember { mutableStateOf("") }
|
||||
|
||||
var serverAddressError by remember { mutableStateOf<String?>(null) }
|
||||
var serverPortError by remember { mutableStateOf<String?>(null) }
|
||||
var clientKeyError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// QR scanner launcher
|
||||
val qrScannerLauncher = rememberLauncherForActivityResult(
|
||||
contract = ScanContract()
|
||||
) { result ->
|
||||
QrScannerUtil.processScanResult(
|
||||
result.contents,
|
||||
object : QrScannerUtil.QrScanCallback {
|
||||
override fun onQrScanned(config: QrServerConfig) {
|
||||
serverAddress = config.server
|
||||
serverPort = config.port.toString()
|
||||
clientKey = config.key
|
||||
Toast.makeText(
|
||||
context,
|
||||
R.string.qr_scan_success,
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
|
||||
override fun onQrScanCancelled() {
|
||||
Toast.makeText(
|
||||
context,
|
||||
R.string.qr_scan_cancelled,
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
|
||||
override fun onQrScanError(errorMessage: String) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.qr_scan_error, errorMessage),
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Camera permission launcher
|
||||
val cameraPermissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission()
|
||||
) { isGranted ->
|
||||
if (isGranted) {
|
||||
val options = QrScannerUtil.createScanOptions()
|
||||
qrScannerLauncher.launch(options)
|
||||
} else {
|
||||
Toast.makeText(
|
||||
context,
|
||||
R.string.camera_permission_denied,
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
|
||||
fun validateAndSave() {
|
||||
// Clear previous errors
|
||||
serverAddressError = null
|
||||
serverPortError = null
|
||||
clientKeyError = null
|
||||
|
||||
var hasError = false
|
||||
|
||||
// Validate server address
|
||||
if (serverAddress.isBlank()) {
|
||||
serverAddressError = "Server address is required"
|
||||
hasError = true
|
||||
}
|
||||
|
||||
// Validate server port
|
||||
if (serverPort.isBlank()) {
|
||||
serverPortError = "Server port is required"
|
||||
hasError = true
|
||||
} else {
|
||||
val port = serverPort.toIntOrNull()
|
||||
if (port == null || port <= 0 || port > 65535) {
|
||||
serverPortError = "Port must be between 1 and 65535"
|
||||
hasError = true
|
||||
}
|
||||
}
|
||||
|
||||
// Validate client key
|
||||
if (clientKey.isBlank()) {
|
||||
clientKeyError = "Client key is required"
|
||||
hasError = true
|
||||
}
|
||||
|
||||
if (hasError) return
|
||||
|
||||
// Save settings
|
||||
PreferencesUtil.setServerAddress(context, serverAddress)
|
||||
PreferencesUtil.setServerPort(context, serverPort.toInt())
|
||||
PreferencesUtil.setClientKey(context, clientKey)
|
||||
PreferencesUtil.setSetupCompleted(context, true)
|
||||
|
||||
Toast.makeText(context, "Setup completed successfully!", Toast.LENGTH_SHORT).show()
|
||||
onSetupComplete()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Server Setup") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Navigate back")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.padding(24.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
// Instructions card
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = "Enter your server connection details below, or scan a QR code from your server admin panel to auto-fill.",
|
||||
modifier = Modifier.padding(16.dp),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Server Address
|
||||
OutlinedTextField(
|
||||
value = serverAddress,
|
||||
onValueChange = {
|
||||
serverAddress = it
|
||||
serverAddressError = null
|
||||
},
|
||||
label = { Text("Server Address") },
|
||||
placeholder = { Text("e.g., 192.168.1.100 or example.com") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = serverAddressError != null,
|
||||
supportingText = serverAddressError?.let { { Text(it) } },
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
// Server Port
|
||||
OutlinedTextField(
|
||||
value = serverPort,
|
||||
onValueChange = {
|
||||
serverPort = it
|
||||
serverPortError = null
|
||||
},
|
||||
label = { Text("Server Port") },
|
||||
placeholder = { Text("e.g., 8080") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = serverPortError != null,
|
||||
supportingText = serverPortError?.let { { Text(it) } },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
// Client Key
|
||||
OutlinedTextField(
|
||||
value = clientKey,
|
||||
onValueChange = {
|
||||
clientKey = it
|
||||
clientKeyError = null
|
||||
},
|
||||
label = { Text("Client Key") },
|
||||
placeholder = { Text("Authentication key") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = clientKeyError != null,
|
||||
supportingText = clientKeyError?.let { { Text(it) } },
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// QR Scan Button
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.QrCodeScanner,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Scan QR Code")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Finish Setup Button
|
||||
Button(
|
||||
onClick = { validateAndSave() },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = "Finish Setup",
|
||||
modifier = Modifier.padding(vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
package com.mattintech.simplelogupload.ui.screens
|
||||
|
||||
import android.Manifest
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.QrCodeScanner
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.journeyapps.barcodescanner.ScanContract
|
||||
import com.mattintech.simplelogupload.R
|
||||
import com.mattintech.simplelogupload.model.QrServerConfig
|
||||
import com.mattintech.simplelogupload.util.PreferencesUtil
|
||||
import com.mattintech.simplelogupload.util.QrScannerUtil
|
||||
|
||||
/**
|
||||
* Settings screen for modifying server configuration
|
||||
*
|
||||
* @param onNavigateBack Callback when back button is pressed
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
onNavigateBack: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
// Load existing settings
|
||||
var serverAddress by remember {
|
||||
mutableStateOf(PreferencesUtil.getServerAddress(context))
|
||||
}
|
||||
var serverPort by remember {
|
||||
mutableStateOf(PreferencesUtil.getServerPort(context).toString())
|
||||
}
|
||||
var clientKey by remember {
|
||||
mutableStateOf(PreferencesUtil.getClientKey(context))
|
||||
}
|
||||
|
||||
var serverAddressError by remember { mutableStateOf<String?>(null) }
|
||||
var serverPortError by remember { mutableStateOf<String?>(null) }
|
||||
var clientKeyError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// QR scanner launcher
|
||||
val qrScannerLauncher = rememberLauncherForActivityResult(
|
||||
contract = ScanContract()
|
||||
) { result ->
|
||||
QrScannerUtil.processScanResult(
|
||||
result.contents,
|
||||
object : QrScannerUtil.QrScanCallback {
|
||||
override fun onQrScanned(config: QrServerConfig) {
|
||||
serverAddress = config.server
|
||||
serverPort = config.port.toString()
|
||||
clientKey = config.key
|
||||
Toast.makeText(
|
||||
context,
|
||||
R.string.qr_scan_success,
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
|
||||
override fun onQrScanCancelled() {
|
||||
Toast.makeText(
|
||||
context,
|
||||
R.string.qr_scan_cancelled,
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
|
||||
override fun onQrScanError(errorMessage: String) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.qr_scan_error, errorMessage),
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Camera permission launcher
|
||||
val cameraPermissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission()
|
||||
) { isGranted ->
|
||||
if (isGranted) {
|
||||
val options = QrScannerUtil.createScanOptions()
|
||||
qrScannerLauncher.launch(options)
|
||||
} else {
|
||||
Toast.makeText(
|
||||
context,
|
||||
R.string.camera_permission_denied,
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
|
||||
fun validateAndSave() {
|
||||
// Clear previous errors
|
||||
serverAddressError = null
|
||||
serverPortError = null
|
||||
clientKeyError = null
|
||||
|
||||
var hasError = false
|
||||
|
||||
// Validate server address
|
||||
if (serverAddress.isBlank()) {
|
||||
serverAddressError = "Server address is required"
|
||||
hasError = true
|
||||
}
|
||||
|
||||
// Validate server port
|
||||
if (serverPort.isBlank()) {
|
||||
serverPortError = "Server port is required"
|
||||
hasError = true
|
||||
} else {
|
||||
val port = serverPort.toIntOrNull()
|
||||
if (port == null || port <= 0 || port > 65535) {
|
||||
serverPortError = "Port must be between 1 and 65535"
|
||||
hasError = true
|
||||
}
|
||||
}
|
||||
|
||||
// Validate client key
|
||||
if (clientKey.isBlank()) {
|
||||
clientKeyError = "Client key is required"
|
||||
hasError = true
|
||||
}
|
||||
|
||||
if (hasError) return
|
||||
|
||||
// Save settings
|
||||
PreferencesUtil.setServerAddress(context, serverAddress)
|
||||
PreferencesUtil.setServerPort(context, serverPort.toInt())
|
||||
PreferencesUtil.setClientKey(context, clientKey)
|
||||
|
||||
Toast.makeText(context, "Settings saved successfully!", Toast.LENGTH_SHORT).show()
|
||||
onNavigateBack()
|
||||
}
|
||||
|
||||
fun resetSettings() {
|
||||
// Reload original values
|
||||
serverAddress = PreferencesUtil.getServerAddress(context)
|
||||
serverPort = PreferencesUtil.getServerPort(context).toString()
|
||||
clientKey = PreferencesUtil.getClientKey(context)
|
||||
|
||||
// Clear errors
|
||||
serverAddressError = null
|
||||
serverPortError = null
|
||||
clientKeyError = null
|
||||
|
||||
Toast.makeText(context, "Settings reset", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Settings") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Navigate back")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.padding(24.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Server Configuration",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Server Address
|
||||
OutlinedTextField(
|
||||
value = serverAddress,
|
||||
onValueChange = {
|
||||
serverAddress = it
|
||||
serverAddressError = null
|
||||
},
|
||||
label = { Text("Server Address") },
|
||||
placeholder = { Text("e.g., 192.168.1.100 or example.com") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = serverAddressError != null,
|
||||
supportingText = serverAddressError?.let { { Text(it) } },
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
// Server Port
|
||||
OutlinedTextField(
|
||||
value = serverPort,
|
||||
onValueChange = {
|
||||
serverPort = it
|
||||
serverPortError = null
|
||||
},
|
||||
label = { Text("Server Port") },
|
||||
placeholder = { Text("e.g., 8080") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = serverPortError != null,
|
||||
supportingText = serverPortError?.let { { Text(it) } },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
// Client Key
|
||||
OutlinedTextField(
|
||||
value = clientKey,
|
||||
onValueChange = {
|
||||
clientKey = it
|
||||
clientKeyError = null
|
||||
},
|
||||
label = { Text("Client Key") },
|
||||
placeholder = { Text("Authentication key") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = clientKeyError != null,
|
||||
supportingText = clientKeyError?.let { { Text(it) } },
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// QR Scan Button
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.QrCodeScanner,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Scan QR Code")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Action buttons
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
// Reset Button
|
||||
OutlinedButton(
|
||||
onClick = { resetSettings() },
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text("Reset")
|
||||
}
|
||||
|
||||
// Save Button
|
||||
Button(
|
||||
onClick = { validateAndSave() },
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text("Save")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
package com.mattintech.simplelogupload.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import com.mattintech.simplelogupload.constant.AppConstants
|
||||
import com.mattintech.simplelogupload.controller.FileController
|
||||
import com.mattintech.simplelogupload.controller.UploadController
|
||||
import com.mattintech.simplelogupload.model.UploadResult
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* UI state for the main screen
|
||||
*/
|
||||
data class MainUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val selectedFile: File? = null,
|
||||
val selectedFileName: String? = null,
|
||||
val dumpstateFiles: List<File> = emptyList(),
|
||||
val selectedDumpstateIndices: Set<Int> = emptySet(),
|
||||
val uploadResult: UploadResult? = null,
|
||||
val errorMessage: String? = null,
|
||||
val showUploadParamsDialog: Boolean = false,
|
||||
val showUploadResultDialog: Boolean = false
|
||||
)
|
||||
|
||||
/**
|
||||
* ViewModel for the main screen with upload functionality
|
||||
*/
|
||||
class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private val TAG = AppConstants.APP_TAG + "MainViewModel"
|
||||
|
||||
private val uploadController = UploadController(application)
|
||||
|
||||
private val _uiState = MutableStateFlow(MainUiState())
|
||||
val uiState: StateFlow<MainUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var uploadReceiver: BroadcastReceiver? = null
|
||||
|
||||
init {
|
||||
loadDumpstateFiles()
|
||||
registerUploadReceiver()
|
||||
}
|
||||
|
||||
/**
|
||||
* Register broadcast receiver for upload completion
|
||||
*/
|
||||
private fun registerUploadReceiver() {
|
||||
uploadReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (intent.action) {
|
||||
AppConstants.ACTION_UPLOAD_COMPLETE -> {
|
||||
val code = intent.getStringExtra(AppConstants.EXTRA_UPLOAD_CODE)
|
||||
Log.d(TAG, "Upload completed: $code")
|
||||
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
uploadResult = UploadResult(code ?: "UNKNOWN"),
|
||||
showUploadResultDialog = true
|
||||
)
|
||||
}
|
||||
AppConstants.ACTION_UPLOAD_ERROR -> {
|
||||
val error = intent.getStringExtra(AppConstants.EXTRA_UPLOAD_ERROR)
|
||||
Log.e(TAG, "Upload error: $error")
|
||||
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
errorMessage = error ?: "Unknown error"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val filter = IntentFilter().apply {
|
||||
addAction(AppConstants.ACTION_UPLOAD_COMPLETE)
|
||||
addAction(AppConstants.ACTION_UPLOAD_ERROR)
|
||||
}
|
||||
|
||||
LocalBroadcastManager.getInstance(getApplication())
|
||||
.registerReceiver(uploadReceiver!!, filter)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all dumpstate files from device
|
||||
*/
|
||||
fun loadDumpstateFiles() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val files = FileController.getAllDumpStateFiles()
|
||||
_uiState.value = _uiState.value.copy(
|
||||
dumpstateFiles = files.toList()
|
||||
)
|
||||
Log.d(TAG, "Loaded ${files.size} dumpstate files")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error loading dumpstate files", e)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
errorMessage = "Error loading dumpstate files: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle file selection from file picker
|
||||
*/
|
||||
fun handleFileSelection(uri: Uri) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
// TODO: Convert URI to File using FileController
|
||||
// For now, store URI info
|
||||
val fileName = uri.lastPathSegment ?: "Unknown file"
|
||||
_uiState.value = _uiState.value.copy(
|
||||
selectedFileName = fileName,
|
||||
selectedFile = null // Will need to implement URI to File conversion
|
||||
)
|
||||
Log.d(TAG, "File selected: $fileName")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error handling file selection", e)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
errorMessage = "Error selecting file: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle dumpstate file selection
|
||||
*/
|
||||
fun toggleDumpstateSelection(index: Int) {
|
||||
val currentSelections = _uiState.value.selectedDumpstateIndices.toMutableSet()
|
||||
if (currentSelections.contains(index)) {
|
||||
currentSelections.remove(index)
|
||||
} else {
|
||||
currentSelections.add(index)
|
||||
}
|
||||
_uiState.value = _uiState.value.copy(
|
||||
selectedDumpstateIndices = currentSelections
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Select all dumpstate files
|
||||
*/
|
||||
fun selectAllDumpstates() {
|
||||
val allIndices = _uiState.value.dumpstateFiles.indices.toSet()
|
||||
_uiState.value = _uiState.value.copy(
|
||||
selectedDumpstateIndices = allIndices
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all dumpstate selections
|
||||
*/
|
||||
fun clearDumpstateSelections() {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
selectedDumpstateIndices = emptySet()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Show upload parameters dialog
|
||||
*/
|
||||
fun showUploadParamsDialog() {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
showUploadParamsDialog = true
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide upload parameters dialog
|
||||
*/
|
||||
fun hideUploadParamsDialog() {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
showUploadParamsDialog = false
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide upload result dialog
|
||||
*/
|
||||
fun hideUploadResultDialog() {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
showUploadResultDialog = false,
|
||||
uploadResult = null
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear error message
|
||||
*/
|
||||
fun clearError() {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
errorMessage = null
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a general file
|
||||
*/
|
||||
fun uploadFile(file: File, maxDownloads: Int, expiryHours: Int) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = true,
|
||||
showUploadParamsDialog = false
|
||||
)
|
||||
|
||||
Log.d(TAG, "Starting upload for file: ${file.name}")
|
||||
|
||||
// Upload file and handle result
|
||||
uploadController.uploadFile(file, maxDownloads, expiryHours)
|
||||
.thenAccept { result ->
|
||||
if (result.isSuccess) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
uploadResult = result,
|
||||
showUploadResultDialog = true
|
||||
)
|
||||
} else {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
errorMessage = result.error ?: "Upload failed"
|
||||
)
|
||||
}
|
||||
}
|
||||
.exceptionally { ex ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
errorMessage = "Error uploading file: ${ex.message}"
|
||||
)
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error uploading file", e)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
errorMessage = "Error uploading file: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload selected dumpstate files
|
||||
* Uploads multiple files by combining them into a ZIP first
|
||||
*/
|
||||
fun uploadSelectedDumpstates(maxDownloads: Int, expiryHours: Int) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val files = _uiState.value.selectedDumpstateIndices
|
||||
.map { _uiState.value.dumpstateFiles[it] }
|
||||
|
||||
if (files.isEmpty()) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
errorMessage = "No dumpstate files selected"
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = true,
|
||||
showUploadParamsDialog = false
|
||||
)
|
||||
|
||||
Log.d(TAG, "Starting upload for ${files.size} dumpstate files")
|
||||
|
||||
// For now, upload the first file
|
||||
// TODO: Implement proper multi-file ZIP and upload
|
||||
val firstFile = files.first()
|
||||
uploadController.uploadFile(firstFile, maxDownloads, expiryHours)
|
||||
.thenAccept { result ->
|
||||
if (result.isSuccess) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
uploadResult = result,
|
||||
showUploadResultDialog = true,
|
||||
selectedDumpstateIndices = emptySet()
|
||||
)
|
||||
} else {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
errorMessage = result.error ?: "Upload failed"
|
||||
)
|
||||
}
|
||||
}
|
||||
.exceptionally { ex ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
errorMessage = "Error uploading files: ${ex.message}"
|
||||
)
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error uploading dumpstate files", e)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
errorMessage = "Error uploading files: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
// Unregister broadcast receiver
|
||||
uploadReceiver?.let {
|
||||
LocalBroadcastManager.getInstance(getApplication()).unregisterReceiver(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user