Add integrated onboarding carousel with permissions and server setup

Replaces separate welcome and server setup screens with a unified 5-page
onboarding carousel that guides users through:
- App introduction and backend requirements
- Notification permission with explanation
- All files access permission with explanation
- Server configuration (address, port, key) with QR scanning
- Completion confirmation

Also adds notification permission checks before uploads to ensure upload
progress notifications can be displayed. Users are prompted for permission
if not already granted, then upload proceeds automatically.

Changes:
- Create OnboardingScreen.kt with 5-page carousel flow
- Add onboarding completion tracking to PreferencesUtil
- Update navigation to show onboarding on first launch
- Add notification permission checks to upload tabs
- Add roundIcon attribute to manifest for better icon display
This commit is contained in:
2025-12-27 19:43:49 -05:00
parent 836c870204
commit 4ac3114064
8 changed files with 738 additions and 10 deletions

View File

@@ -15,6 +15,7 @@
<application <application
android:allowBackup="false" android:allowBackup="false"
android:icon="@drawable/ic_app_icon" android:icon="@drawable/ic_app_icon"
android:roundIcon="@drawable/ic_app_icon"
android:label="SimpleLogUpload" android:label="SimpleLogUpload"
android:networkSecurityConfig="@xml/network_security_config" android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true" android:supportsRtl="true"

View File

@@ -14,6 +14,7 @@ import androidx.navigation.compose.rememberNavController
import com.mattintech.simplelogupload.navigation.AppNavigation import com.mattintech.simplelogupload.navigation.AppNavigation
import com.mattintech.simplelogupload.navigation.Screen import com.mattintech.simplelogupload.navigation.Screen
import com.mattintech.simplelogupload.ui.theme.SimpleLogUploadTheme import com.mattintech.simplelogupload.ui.theme.SimpleLogUploadTheme
import com.mattintech.simplelogupload.util.PermissionUtil
import com.mattintech.simplelogupload.util.PreferencesUtil import com.mattintech.simplelogupload.util.PreferencesUtil
/** /**
@@ -61,6 +62,12 @@ class ComposeMainActivity : ComponentActivity() {
* Determine which screen to show first based on app configuration * Determine which screen to show first based on app configuration
*/ */
private fun getStartDestination(): String { private fun getStartDestination(): String {
// Check if onboarding has been completed first
if (!PreferencesUtil.isOnboardingCompleted(this)) {
return Screen.Onboarding.route
}
// Then check if setup is complete
return if (PreferencesUtil.isSetupCompleted(this) && return if (PreferencesUtil.isSetupCompleted(this) &&
PreferencesUtil.areServerSettingsConfigured(this)) { PreferencesUtil.areServerSettingsConfigured(this)) {
Screen.Main.route Screen.Main.route

View File

@@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import com.mattintech.simplelogupload.ui.screens.OnboardingScreen
import com.mattintech.simplelogupload.ui.screens.WelcomeScreen import com.mattintech.simplelogupload.ui.screens.WelcomeScreen
import com.mattintech.simplelogupload.ui.screens.ServerSetupScreen import com.mattintech.simplelogupload.ui.screens.ServerSetupScreen
import com.mattintech.simplelogupload.ui.screens.MainScreen import com.mattintech.simplelogupload.ui.screens.MainScreen
@@ -25,6 +26,18 @@ fun AppNavigation(
navController = navController, navController = navController,
startDestination = startDestination startDestination = startDestination
) { ) {
// Onboarding carousel - shown on very first launch with permissions and server setup
composable(Screen.Onboarding.route) {
OnboardingScreen(
onOnboardingComplete = {
navController.navigate(Screen.Main.route) {
// Clear backstack so user can't go back to onboarding
popUpTo(Screen.Onboarding.route) { inclusive = true }
}
}
)
}
// Welcome screen - shown on first launch // Welcome screen - shown on first launch
composable(Screen.Welcome.route) { composable(Screen.Welcome.route) {
WelcomeScreen( WelcomeScreen(

View File

@@ -4,6 +4,11 @@ package com.mattintech.simplelogupload.navigation
* Sealed class representing all navigation destinations in the app * Sealed class representing all navigation destinations in the app
*/ */
sealed class Screen(val route: String) { sealed class Screen(val route: String) {
/**
* Onboarding carousel with app intro and permissions
*/
data object Onboarding : Screen("onboarding")
/** /**
* Welcome/onboarding screen shown on first launch * Welcome/onboarding screen shown on first launch
*/ */

View File

@@ -0,0 +1,601 @@
package com.mattintech.simplelogupload.ui.screens
import android.Manifest
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.Settings
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.CloudUpload
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.QrCodeScanner
import androidx.compose.material.icons.filled.Storage
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.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
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.PermissionUtil
import com.mattintech.simplelogupload.util.PreferencesUtil
import com.mattintech.simplelogupload.util.QrScannerUtil
import kotlinx.coroutines.launch
/**
* Onboarding screen with carousel explaining app and permissions
*
* @param onOnboardingComplete Callback when onboarding is complete
*/
@Composable
fun OnboardingScreen(
onOnboardingComplete: () -> Unit
) {
val context = LocalContext.current
val activity = context as Activity
val pagerState = rememberPagerState(pageCount = { 5 })
val scope = rememberCoroutineScope()
// Permission states
var notificationPermissionGranted by remember {
mutableStateOf(PermissionUtil.hasNotificationPermission(context))
}
var filesPermissionGranted by remember {
mutableStateOf(PermissionUtil.hasAllFilesAccessPermission())
}
// Server configuration states
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) }
// Notification permission launcher
val notificationPermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { isGranted ->
notificationPermissionGranted = isGranted
if (isGranted) {
// Auto-advance to next page after permission granted
scope.launch {
pagerState.animateScrollToPage(pagerState.currentPage + 1)
}
} else {
Toast.makeText(
context,
"Notification permission helps you track upload progress",
Toast.LENGTH_SHORT
).show()
// Still allow advancing even if denied
scope.launch {
pagerState.animateScrollToPage(pagerState.currentPage + 1)
}
}
}
// Files access launcher
val filesPermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
) {
filesPermissionGranted = PermissionUtil.hasAllFilesAccessPermission()
if (filesPermissionGranted) {
// Auto-advance to server setup page
scope.launch {
pagerState.animateScrollToPage(pagerState.currentPage + 1)
}
} else {
Toast.makeText(
context,
"File access is needed to upload files from your device",
Toast.LENGTH_SHORT
).show()
// Still allow advancing even if denied
scope.launch {
pagerState.animateScrollToPage(pagerState.currentPage + 1)
}
}
}
// 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 (for QR scanning)
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()
}
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
// Pager
HorizontalPager(
state = pagerState,
modifier = Modifier.weight(1f),
userScrollEnabled = false // Disable manual scrolling
) { page ->
when (page) {
0 -> WelcomePage(
onContinue = {
scope.launch {
pagerState.animateScrollToPage(1)
}
}
)
1 -> NotificationPermissionPage(
isGranted = notificationPermissionGranted,
onRequestPermission = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
} else {
// On older versions, permission is granted by default
notificationPermissionGranted = true
scope.launch {
pagerState.animateScrollToPage(pagerState.currentPage + 1)
}
}
}
)
2 -> FilesPermissionPage(
isGranted = filesPermissionGranted,
onRequestPermission = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
try {
val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
intent.data = Uri.parse("package:" + context.packageName)
filesPermissionLauncher.launch(intent)
} catch (e: Exception) {
val intent = Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION)
filesPermissionLauncher.launch(intent)
}
} else {
// On older versions, permission is granted by default
filesPermissionGranted = true
scope.launch {
pagerState.animateScrollToPage(pagerState.currentPage + 1)
}
}
}
)
3 -> ServerSetupPage(
serverAddress = serverAddress,
serverPort = serverPort,
clientKey = clientKey,
serverAddressError = serverAddressError,
serverPortError = serverPortError,
clientKeyError = clientKeyError,
onServerAddressChange = {
serverAddress = it
serverAddressError = null
},
onServerPortChange = {
serverPort = it
serverPortError = null
},
onClientKeyChange = {
clientKey = it
clientKeyError = null
},
onScanQr = {
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
},
onContinue = {
// Validate server settings
var hasError = false
if (serverAddress.isBlank()) {
serverAddressError = "Server address is required"
hasError = true
}
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
}
}
if (clientKey.isBlank()) {
clientKeyError = "Client key is required"
hasError = true
}
if (!hasError) {
// Save settings
PreferencesUtil.setServerAddress(context, serverAddress)
PreferencesUtil.setServerPort(context, serverPort.toInt())
PreferencesUtil.setClientKey(context, clientKey)
PreferencesUtil.setSetupCompleted(context, true)
// Advance to final page
scope.launch {
pagerState.animateScrollToPage(pagerState.currentPage + 1)
}
}
}
)
4 -> ReadyPage(
onGetStarted = {
// Mark onboarding as complete
PreferencesUtil.setOnboardingCompleted(context, true)
onOnboardingComplete()
}
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Page indicator
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
repeat(5) { iteration ->
val color = if (pagerState.currentPage == iteration) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f)
}
Box(
modifier = Modifier
.padding(4.dp)
.size(8.dp)
.background(color, shape = MaterialTheme.shapes.small)
)
}
}
Spacer(modifier = Modifier.height(16.dp))
}
}
/**
* Welcome page explaining the app
*/
@Composable
private fun WelcomePage(
onContinue: () -> Unit
) {
OnboardingPageTemplate(
icon = Icons.Default.CloudUpload,
title = "Welcome to SimpleLogUpload",
description = "A simple and secure way to upload files to your server.\n\n" +
"This app requires a backend server to function. Your administrator should have " +
"provided you with server details.\n\n" +
"Let's get your app set up!",
buttonText = "Continue",
onButtonClick = onContinue
)
}
/**
* Notification permission page
*/
@Composable
private fun NotificationPermissionPage(
isGranted: Boolean,
onRequestPermission: () -> Unit
) {
OnboardingPageTemplate(
icon = Icons.Default.Notifications,
title = "Upload Progress Notifications",
description = "SimpleLogUpload needs notification permission to:\n\n" +
"• Show upload progress in real-time\n" +
"• Display upload completion status\n" +
"• Keep you informed about file transfers\n\n" +
(if (isGranted) "✓ Permission granted!" else "This helps you track uploads even when the app is in the background."),
buttonText = if (isGranted) "Continue" else "Grant Permission",
onButtonClick = onRequestPermission,
isPermissionGranted = isGranted
)
}
/**
* Files permission page
*/
@Composable
private fun FilesPermissionPage(
isGranted: Boolean,
onRequestPermission: () -> Unit
) {
OnboardingPageTemplate(
icon = Icons.Default.Folder,
title = "File Access Permission",
description = "SimpleLogUpload needs access to your files to:\n\n" +
"• Browse and select files to upload\n" +
"• Access dumpstate files (on Samsung devices)\n" +
"• Read files from any location on your device\n\n" +
(if (isGranted) "✓ Permission granted!" else "You'll be taken to Settings to grant this permission."),
buttonText = if (isGranted) "Continue" else "Grant Permission",
onButtonClick = onRequestPermission,
isPermissionGranted = isGranted
)
}
/**
* Server setup page
*/
@Composable
private fun ServerSetupPage(
serverAddress: String,
serverPort: String,
clientKey: String,
serverAddressError: String?,
serverPortError: String?,
clientKeyError: String?,
onServerAddressChange: (String) -> Unit,
onServerPortChange: (String) -> Unit,
onClientKeyChange: (String) -> Unit,
onScanQr: () -> Unit,
onContinue: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceBetween
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.weight(1f)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.Top
) {
Spacer(modifier = Modifier.height(8.dp))
Icon(
imageVector = Icons.Default.Storage,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "Server Configuration",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Enter your server details or scan a QR code from your admin panel.",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
// Server Address
OutlinedTextField(
value = serverAddress,
onValueChange = onServerAddressChange,
label = { Text("Server Address") },
placeholder = { Text("e.g., 192.168.1.100") },
modifier = Modifier.fillMaxWidth(),
isError = serverAddressError != null,
supportingText = serverAddressError?.let { { Text(it) } },
singleLine = true
)
Spacer(modifier = Modifier.height(12.dp))
// Server Port
OutlinedTextField(
value = serverPort,
onValueChange = onServerPortChange,
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
)
Spacer(modifier = Modifier.height(12.dp))
// Client Key
OutlinedTextField(
value = clientKey,
onValueChange = onClientKeyChange,
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(16.dp))
// QR Scan Button
OutlinedButton(
onClick = onScanQr,
modifier = Modifier.fillMaxWidth()
) {
Icon(
Icons.Default.QrCodeScanner,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Scan QR Code")
}
}
Button(
onClick = onContinue,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp)
) {
Text(
text = "Continue",
modifier = Modifier.padding(vertical = 8.dp)
)
}
}
}
/**
* Ready page - final page
*/
@Composable
private fun ReadyPage(
onGetStarted: () -> Unit
) {
OnboardingPageTemplate(
icon = Icons.Default.Check,
title = "You're All Set!",
description = "Your app is now configured and ready to use!\n\n" +
"✓ Permissions granted\n" +
"✓ Server configured\n\n" +
"You can now upload files to your server. " +
"Tap the button below to start using SimpleLogUpload!",
buttonText = "Start Using App",
onButtonClick = onGetStarted
)
}
/**
* Reusable template for onboarding pages
*/
@Composable
private fun OnboardingPageTemplate(
icon: androidx.compose.ui.graphics.vector.ImageVector,
title: String,
description: String,
buttonText: String,
onButtonClick: () -> Unit,
isPermissionGranted: Boolean = false
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceBetween
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(32.dp))
Text(
text = title,
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = description,
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Button(
onClick = onButtonClick,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp)
) {
Text(
text = buttonText,
modifier = Modifier.padding(vertical = 8.dp)
)
}
}
}

View File

@@ -1,6 +1,10 @@
package com.mattintech.simplelogupload.ui.screens.tabs package com.mattintech.simplelogupload.ui.screens.tabs
import android.Manifest
import android.os.Build
import android.widget.Toast import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
@@ -13,6 +17,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.mattintech.simplelogupload.ui.components.UploadParamsDialog import com.mattintech.simplelogupload.ui.components.UploadParamsDialog
import com.mattintech.simplelogupload.ui.components.UploadResultDialog import com.mattintech.simplelogupload.ui.components.UploadResultDialog
import com.mattintech.simplelogupload.util.PermissionUtil
import com.mattintech.simplelogupload.util.PreferencesUtil import com.mattintech.simplelogupload.util.PreferencesUtil
import com.mattintech.simplelogupload.viewmodel.MainViewModel import com.mattintech.simplelogupload.viewmodel.MainViewModel
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@@ -33,6 +38,42 @@ fun DumpstateTab(
val context = LocalContext.current val context = LocalContext.current
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
// State to track pending upload parameters
var pendingUpload by remember { mutableStateOf<Pair<Int, Int>?>(null) }
// Notification permission launcher
val notificationPermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { isGranted ->
if (isGranted) {
// Permission granted, proceed with pending upload
pendingUpload?.let { (maxDownloads, expiryHours) ->
viewModel.uploadSelectedDumpstates(maxDownloads, expiryHours)
}
pendingUpload = null
} else {
Toast.makeText(
context,
"Notification permission is required to show upload progress",
Toast.LENGTH_LONG
).show()
}
}
// Helper function to check permission and upload
val uploadWithPermissionCheck: (Int, Int) -> Unit = { maxDownloads, expiryHours ->
if (PermissionUtil.hasNotificationPermission(context)) {
// Permission already granted, proceed with upload
viewModel.uploadSelectedDumpstates(maxDownloads, expiryHours)
} else {
// Need to request permission first
pendingUpload = Pair(maxDownloads, expiryHours)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}
// Show error toast // Show error toast
LaunchedEffect(uiState.errorMessage) { LaunchedEffect(uiState.errorMessage) {
uiState.errorMessage?.let { error -> uiState.errorMessage?.let { error ->
@@ -145,7 +186,7 @@ fun DumpstateTab(
} else { } else {
val maxDownloads = PreferencesUtil.getDefaultMaxDownloads(context) val maxDownloads = PreferencesUtil.getDefaultMaxDownloads(context)
val expiryHours = PreferencesUtil.getDefaultExpiryHours(context) val expiryHours = PreferencesUtil.getDefaultExpiryHours(context)
viewModel.uploadSelectedDumpstates(maxDownloads, expiryHours) uploadWithPermissionCheck(maxDownloads, expiryHours)
} }
}, },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -177,7 +218,7 @@ fun DumpstateTab(
UploadParamsDialog( UploadParamsDialog(
onDismiss = { viewModel.hideUploadParamsDialog() }, onDismiss = { viewModel.hideUploadParamsDialog() },
onConfirm = { maxDownloads, expiryHours -> onConfirm = { maxDownloads, expiryHours ->
viewModel.uploadSelectedDumpstates(maxDownloads, expiryHours) uploadWithPermissionCheck(maxDownloads, expiryHours)
} }
) )
} }

View File

@@ -1,5 +1,6 @@
package com.mattintech.simplelogupload.ui.screens.tabs package com.mattintech.simplelogupload.ui.screens.tabs
import android.Manifest
import android.app.Activity import android.app.Activity
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
@@ -40,6 +41,30 @@ fun GeneralUploadTab(
val activity = context as Activity val activity = context as Activity
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
// State to track pending upload parameters
var pendingUpload by remember { mutableStateOf<Pair<Int, Int>?>(null) }
// Notification permission launcher
val notificationPermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { isGranted ->
if (isGranted) {
// Permission granted, proceed with pending upload
pendingUpload?.let { (maxDownloads, expiryHours) ->
uiState.selectedFile?.let { file ->
viewModel.uploadFile(file, maxDownloads, expiryHours)
}
}
pendingUpload = null
} else {
Toast.makeText(
context,
"Notification permission is required to show upload progress",
Toast.LENGTH_LONG
).show()
}
}
// File picker launcher // File picker launcher
val filePickerLauncher = rememberLauncherForActivityResult( val filePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent() contract = ActivityResultContracts.GetContent()
@@ -65,6 +90,22 @@ fun GeneralUploadTab(
} }
} }
// Helper function to check permission and upload
val uploadWithPermissionCheck: (Int, Int) -> Unit = { maxDownloads, expiryHours ->
if (PermissionUtil.hasNotificationPermission(context)) {
// Permission already granted, proceed with upload
uiState.selectedFile?.let { file ->
viewModel.uploadFile(file, maxDownloads, expiryHours)
}
} else {
// Need to request permission first
pendingUpload = Pair(maxDownloads, expiryHours)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}
// Show error toast // Show error toast
LaunchedEffect(uiState.errorMessage) { LaunchedEffect(uiState.errorMessage) {
uiState.errorMessage?.let { error -> uiState.errorMessage?.let { error ->
@@ -173,9 +214,7 @@ fun GeneralUploadTab(
} else { } else {
val maxDownloads = PreferencesUtil.getDefaultMaxDownloads(context) val maxDownloads = PreferencesUtil.getDefaultMaxDownloads(context)
val expiryHours = PreferencesUtil.getDefaultExpiryHours(context) val expiryHours = PreferencesUtil.getDefaultExpiryHours(context)
uiState.selectedFile?.let { file -> uploadWithPermissionCheck(maxDownloads, expiryHours)
viewModel.uploadFile(file, maxDownloads, expiryHours)
}
} }
}, },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -205,9 +244,7 @@ fun GeneralUploadTab(
UploadParamsDialog( UploadParamsDialog(
onDismiss = { viewModel.hideUploadParamsDialog() }, onDismiss = { viewModel.hideUploadParamsDialog() },
onConfirm = { maxDownloads, expiryHours -> onConfirm = { maxDownloads, expiryHours ->
uiState.selectedFile?.let { file -> uploadWithPermissionCheck(maxDownloads, expiryHours)
viewModel.uploadFile(file, maxDownloads, expiryHours)
}
} }
) )
} }

View File

@@ -14,6 +14,7 @@ public class PreferencesUtil {
public static final String KEY_SERVER_PORT = "server_port"; public static final String KEY_SERVER_PORT = "server_port";
public static final String KEY_CLIENT_KEY = "client_key"; public static final String KEY_CLIENT_KEY = "client_key";
public static final String KEY_SETUP_COMPLETED = "setup_completed"; public static final String KEY_SETUP_COMPLETED = "setup_completed";
public static final String KEY_ONBOARDING_COMPLETED = "onboarding_completed";
public static final String KEY_DEFAULT_MAX_DOWNLOADS = "default_max_downloads"; public static final String KEY_DEFAULT_MAX_DOWNLOADS = "default_max_downloads";
public static final String KEY_DEFAULT_EXPIRY_HOURS = "default_expiry_hours"; public static final String KEY_DEFAULT_EXPIRY_HOURS = "default_expiry_hours";
public static final String KEY_ASK_BEFORE_UPLOAD = "ask_before_upload"; public static final String KEY_ASK_BEFORE_UPLOAD = "ask_before_upload";
@@ -23,6 +24,7 @@ public class PreferencesUtil {
private static final int DEFAULT_SERVER_PORT = 0; private static final int DEFAULT_SERVER_PORT = 0;
private static final String DEFAULT_CLIENT_KEY = ""; private static final String DEFAULT_CLIENT_KEY = "";
private static final boolean DEFAULT_SETUP_COMPLETED = false; private static final boolean DEFAULT_SETUP_COMPLETED = false;
private static final boolean DEFAULT_ONBOARDING_COMPLETED = false;
private static final int DEFAULT_MAX_DOWNLOADS = 5; private static final int DEFAULT_MAX_DOWNLOADS = 5;
private static final int DEFAULT_EXPIRY_HOURS = 24; private static final int DEFAULT_EXPIRY_HOURS = 24;
private static final boolean DEFAULT_ASK_BEFORE_UPLOAD = true; private static final boolean DEFAULT_ASK_BEFORE_UPLOAD = true;
@@ -132,6 +134,7 @@ public class PreferencesUtil {
editor.putInt(KEY_SERVER_PORT, DEFAULT_SERVER_PORT); editor.putInt(KEY_SERVER_PORT, DEFAULT_SERVER_PORT);
editor.putString(KEY_CLIENT_KEY, DEFAULT_CLIENT_KEY); editor.putString(KEY_CLIENT_KEY, DEFAULT_CLIENT_KEY);
editor.putBoolean(KEY_SETUP_COMPLETED, DEFAULT_SETUP_COMPLETED); editor.putBoolean(KEY_SETUP_COMPLETED, DEFAULT_SETUP_COMPLETED);
editor.putBoolean(KEY_ONBOARDING_COMPLETED, DEFAULT_ONBOARDING_COMPLETED);
editor.putInt(KEY_DEFAULT_MAX_DOWNLOADS, DEFAULT_MAX_DOWNLOADS); editor.putInt(KEY_DEFAULT_MAX_DOWNLOADS, DEFAULT_MAX_DOWNLOADS);
editor.putInt(KEY_DEFAULT_EXPIRY_HOURS, DEFAULT_EXPIRY_HOURS); editor.putInt(KEY_DEFAULT_EXPIRY_HOURS, DEFAULT_EXPIRY_HOURS);
editor.putBoolean(KEY_ASK_BEFORE_UPLOAD, DEFAULT_ASK_BEFORE_UPLOAD); editor.putBoolean(KEY_ASK_BEFORE_UPLOAD, DEFAULT_ASK_BEFORE_UPLOAD);
@@ -150,14 +153,34 @@ public class PreferencesUtil {
/** /**
* Set the setup completed status * Set the setup completed status
* *
* @param context The application context * @param context The application context
* @param completed True if setup has been completed, false otherwise * @param completed True if setup has been completed, false otherwise
*/ */
public static void setSetupCompleted(Context context, boolean completed) { public static void setSetupCompleted(Context context, boolean completed) {
getPreferences(context).edit().putBoolean(KEY_SETUP_COMPLETED, completed).apply(); getPreferences(context).edit().putBoolean(KEY_SETUP_COMPLETED, completed).apply();
} }
/**
* Check if onboarding has been completed
*
* @param context The application context
* @return True if onboarding has been completed, false otherwise
*/
public static boolean isOnboardingCompleted(Context context) {
return getPreferences(context).getBoolean(KEY_ONBOARDING_COMPLETED, DEFAULT_ONBOARDING_COMPLETED);
}
/**
* Set the onboarding completed status
*
* @param context The application context
* @param completed True if onboarding has been completed, false otherwise
*/
public static void setOnboardingCompleted(Context context, boolean completed) {
getPreferences(context).edit().putBoolean(KEY_ONBOARDING_COMPLETED, completed).apply();
}
/** /**
* Get the default max downloads from preferences * Get the default max downloads from preferences
* *