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))
|
languageVersion.set(JavaLanguageVersion.of(17))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
}
|
||||||
|
|
||||||
|
composeOptions {
|
||||||
|
kotlinCompilerExtensionVersion = '1.5.8'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
@@ -77,6 +85,23 @@ dependencies {
|
|||||||
// Coroutines
|
// Coroutines
|
||||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"
|
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
|
// Testing
|
||||||
testImplementation 'junit:junit:4.13.2'
|
testImplementation 'junit:junit:4.13.2'
|
||||||
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
|
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
|
||||||
|
|||||||
@@ -22,22 +22,25 @@
|
|||||||
android:usesCleartextTraffic="false"
|
android:usesCleartextTraffic="false"
|
||||||
android:theme="@style/Theme.AppCompat.DayNight.DarkActionBar">
|
android:theme="@style/Theme.AppCompat.DayNight.DarkActionBar">
|
||||||
|
|
||||||
<!-- Main Activity -->
|
<!-- NEW: Compose Main Activity (LAUNCHER) -->
|
||||||
<activity
|
<activity
|
||||||
android:name=".view.MainActivity"
|
android:name=".ComposeMainActivity"
|
||||||
android:exported="true">
|
android:exported="true"
|
||||||
|
android:theme="@style/Theme.AppCompat.Light.NoActionBar">
|
||||||
|
|
||||||
|
<!-- LAUNCHER - App starts here -->
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
<!-- Share intent filter for files -->
|
<!-- Share intent filter for files -->
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.SEND" />
|
<action android:name="android.intent.action.SEND" />
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<data android:mimeType="*/*" />
|
<data android:mimeType="*/*" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
<!-- Share intent filter for multiple files -->
|
<!-- Share intent filter for multiple files -->
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||||
@@ -45,7 +48,14 @@
|
|||||||
<data android:mimeType="*/*" />
|
<data android:mimeType="*/*" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</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 -->
|
<!-- Upload History Activity -->
|
||||||
<activity
|
<activity
|
||||||
android:name=".view.UploadHistoryActivity"
|
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.
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
buildscript {
|
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 {
|
repositories {
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
|
|||||||
Reference in New Issue
Block a user