diff --git a/.gitignore b/.gitignore index dbfe53da..39b98b17 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ output/ .claude Android/app/src/main/assets/binaries/ Android/app/src/main/assets/supported_locales.txt +md3/ # Keystores *.keystore @@ -25,4 +26,4 @@ compile_commands.json *.qcow2 # Android stuff -.gradle \ No newline at end of file +.gradle diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerConfigForm.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerConfigForm.kt index 07f0c6ad..cba1b8ad 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerConfigForm.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerConfigForm.kt @@ -141,8 +141,7 @@ fun ContainerConfigForm( ) { Surface( modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp) + .fillMaxWidth(0.92f) .imePadding(), shape = RoundedCornerShape(24.dp), color = MaterialTheme.colorScheme.surfaceContainer, @@ -168,43 +167,19 @@ fun ContainerConfigForm( Text(context.getString(R.string.read_only), style = MaterialTheme.typography.bodyMedium) Switch(checked = roEnabled, onCheckedChange = { roEnabled = it }) } - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Surface( - modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable(onClick = { clearFocus(); showDestDialog = false }), - shape = RoundedCornerShape(14.dp), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), - tonalElevation = 0.dp - ) { - Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) { - Text(context.getString(R.string.cancel), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold) + DialogFooterRow( + dismissLabel = context.getString(R.string.cancel), + confirmLabel = context.getString(R.string.ok), + onDismiss = { clearFocus(); showDestDialog = false }, + onConfirm = { + clearFocus() + if (destPath.isNotBlank()) { + onStateChange(state.copy(bindMounts = state.bindMounts + BindMount(tempSrcPath, destPath, roEnabled))) + showDestDialog = false } - } - Surface( - modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable( - enabled = destPath.startsWith("/"), - onClick = { - clearFocus() - if (destPath.isNotBlank()) { - onStateChange(state.copy(bindMounts = state.bindMounts + BindMount(tempSrcPath, destPath, roEnabled))) - showDestDialog = false - } - } - ), - shape = RoundedCornerShape(14.dp), - color = if (destPath.startsWith("/")) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), - tonalElevation = 0.dp - ) { - Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) { - Text( - context.getString(R.string.ok), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - color = if (destPath.startsWith("/")) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - ) - } - } - } + }, + confirmEnabled = destPath.startsWith("/") + ) } } } diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/DialogFooterRow.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/DialogFooterRow.kt index 901564a9..4f1144ce 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/DialogFooterRow.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/DialogFooterRow.kt @@ -32,38 +32,56 @@ fun DialogFooterRow( confirmLabel: String, onDismiss: () -> Unit, onConfirm: () -> Unit, - modifier: Modifier = Modifier, + modifier: Modifier = Modifier.padding(vertical = 4.dp), confirmEnabled: Boolean = true, cancelBorderAlpha: Float = 0.4f, textFontWeight: FontWeight = FontWeight.SemiBold, confirmColor: Color = MaterialTheme.colorScheme.primary, confirmContentColor: Color = MaterialTheme.colorScheme.onPrimary, ) { - Row(modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Surface( - modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable(onClick = onDismiss), - shape = RoundedCornerShape(14.dp), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = cancelBorderAlpha)), - tonalElevation = 0.dp + Surface( + modifier = modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(20.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f)) + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(4.dp) ) { - Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) { - Text(dismissLabel, style = MaterialTheme.typography.labelLarge, fontWeight = textFontWeight) + // Dismiss/Cancel Button + Surface( + onClick = onDismiss, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = cancelBorderAlpha)) + ) { + Box(modifier = Modifier.padding(vertical = 12.dp), contentAlignment = Alignment.Center) { + Text( + text = dismissLabel, + style = MaterialTheme.typography.labelLarge, + fontWeight = textFontWeight + ) + } } - } - Surface( - modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable(enabled = confirmEnabled, onClick = onConfirm), - shape = RoundedCornerShape(14.dp), - color = if (confirmEnabled) confirmColor else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), - tonalElevation = 0.dp - ) { - Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) { - Text( - confirmLabel, - style = MaterialTheme.typography.labelLarge, - fontWeight = textFontWeight, - color = if (confirmEnabled) confirmContentColor else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - ) + + // Confirm/OK Button + Surface( + onClick = onConfirm, + enabled = confirmEnabled, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(16.dp), + color = if (confirmEnabled) confirmColor else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f) + ) { + Box(modifier = Modifier.padding(vertical = 12.dp), contentAlignment = Alignment.Center) { + Text( + text = confirmLabel, + style = MaterialTheme.typography.labelLarge, + fontWeight = textFontWeight, + color = if (confirmEnabled) confirmContentColor else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + ) + } } } } diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/HardwareAccessDialog.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/HardwareAccessDialog.kt index 74d0ce9a..9436a604 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/HardwareAccessDialog.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/HardwareAccessDialog.kt @@ -34,7 +34,7 @@ fun HardwareAccessDialog( ) { Surface( modifier = Modifier - .fillMaxWidth(0.95f) + .fillMaxWidth(0.92f) .wrapContentHeight(), shape = RoundedCornerShape(24.dp), color = MaterialTheme.colorScheme.surfaceContainer, diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/PrivilegedModeDialog.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/PrivilegedModeDialog.kt index d40cc94b..dc100a8b 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/PrivilegedModeDialog.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/PrivilegedModeDialog.kt @@ -76,7 +76,7 @@ fun PrivilegedModeDialog( ) { Surface( modifier = Modifier - .fillMaxWidth(0.95f) + .fillMaxWidth(0.92f) .wrapContentHeight(), shape = RoundedCornerShape(24.dp), color = MaterialTheme.colorScheme.surfaceContainer, diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/RootfsRepoSheet.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/RootfsRepoSheet.kt index 650520ea..3079ce2e 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/RootfsRepoSheet.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/RootfsRepoSheet.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -486,10 +487,10 @@ private fun RootfsAssetCard( progress = { state.percent / 100f }, modifier = Modifier .fillMaxWidth() - .height(4.dp) - .clip(RoundedCornerShape(2.dp)), + .height(4.dp), color = MaterialTheme.colorScheme.tertiary, // Match state pill - trackColor = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.15f) + trackColor = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.15f), + strokeCap = StrokeCap.Round ) Text( text = "${state.percent}%", diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerDetailsScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerDetailsScreen.kt index 378046be..17691a14 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerDetailsScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerDetailsScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -548,9 +549,10 @@ private fun SparseDiskUsageCard( progress = { animatedFraction }, modifier = Modifier .fillMaxWidth() - .height(8.dp) - .clip(RoundedCornerShape(4.dp)), - trackColor = MaterialTheme.colorScheme.surfaceVariant + .height(8.dp), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + strokeCap = StrokeCap.Round ) } } diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerTerminalScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerTerminalScreen.kt index 2a512405..54d35a18 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerTerminalScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerTerminalScreen.kt @@ -10,6 +10,13 @@ import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.border import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState @@ -21,6 +28,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Close import androidx.compose.material3.* +import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -45,6 +53,7 @@ import com.droidspaces.app.util.ContainerOSInfoManager import com.droidspaces.app.util.PreferencesManager import com.droidspaces.app.ui.util.LoadingIndicator import com.droidspaces.app.ui.util.LoadingSize +import com.droidspaces.app.ui.component.DialogFooterRow import com.termux.terminal.TerminalSession import com.termux.view.TerminalView import java.lang.ref.WeakReference @@ -59,7 +68,7 @@ private data class TerminalTab( val label: String, ) -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class) @Composable fun ContainerTerminalScreen( containerName: String, @@ -98,6 +107,7 @@ fun ContainerTerminalScreen( val tabs = remember { mutableStateListOf() } var activeTabId by remember { mutableStateOf("") } var showUserPicker by remember { mutableStateOf(false) } + var tabToClose by remember { mutableStateOf(null) } // Resolve hostname reactively; picker is shown only after binder+hostname are both ready var hostname by remember(containerName) { @@ -192,10 +202,23 @@ fun ContainerTerminalScreen( ) } + if (tabToClose != null) { + CloseSessionDialog( + onConfirm = { + val tab = tabToClose!! + tabToClose = null + closeTab(tab) + }, + onDismiss = { + tabToClose = null + } + ) + } + Scaffold( topBar = { Column { - TopAppBar( + CenterAlignedTopAppBar( title = { Text( containerName, @@ -215,58 +238,69 @@ fun ContainerTerminalScreen( Icon(Icons.Default.Add, contentDescription = "New tab") } }, - colors = TopAppBarDefaults.topAppBarColors( + colors = TopAppBarDefaults.centerAlignedTopAppBarColors( containerColor = MaterialTheme.colorScheme.surfaceContainerLow ) ) if (tabs.isNotEmpty()) { + val selectedTabIndex = tabs.indexOfFirst { it.id == activeTabId }.coerceAtLeast(0) ScrollableTabRow( - selectedTabIndex = tabs.indexOfFirst { it.id == activeTabId }.coerceAtLeast(0), + selectedTabIndex = selectedTabIndex, containerColor = MaterialTheme.colorScheme.surfaceContainerLow, contentColor = MaterialTheme.colorScheme.primary, edgePadding = 0.dp, divider = {}, - modifier = Modifier.fillMaxWidth() + indicator = { tabPositions -> + if (selectedTabIndex < tabPositions.size) { + val position = tabPositions[selectedTabIndex] + Box( + Modifier + .tabIndicatorOffset(position) + .fillMaxHeight() + .padding(horizontal = 4.dp, vertical = 6.dp) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f), + shape = RoundedCornerShape(16.dp) + ) + .padding(4.dp) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.4f), + shape = RoundedCornerShape(12.dp) + ) + .background( + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f), + shape = RoundedCornerShape(12.dp) + ) + ) + } + }, + modifier = Modifier.fillMaxWidth().height(48.dp) ) { tabs.forEach { tab -> val isSelected = tab.id == activeTabId - Tab( - selected = isSelected, - onClick = { activeTabId = tab.id }, - modifier = Modifier.height(40.dp) + Box( + modifier = Modifier + .height(48.dp) + .clip(RoundedCornerShape(16.dp)) + .combinedClickable( + onClick = { activeTabId = tab.id }, + onLongClick = { tabToClose = tab } + ), + contentAlignment = Alignment.Center ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier.padding(horizontal = 8.dp) - ) { - Text( - tab.label, - style = MaterialTheme.typography.labelMedium, - fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.widthIn(max = 120.dp) - ) - Box( - Modifier.size(16.dp).clip(CircleShape), - contentAlignment = Alignment.Center - ) { - IconButton( - onClick = { closeTab(tab) }, - modifier = Modifier.size(16.dp) - ) { - Icon( - Icons.Default.Close, - contentDescription = "Close tab", - modifier = Modifier.size(12.dp), - tint = if (isSelected) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } + Text( + text = tab.label, + style = MaterialTheme.typography.labelMedium, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = if (isSelected) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) } } } @@ -286,13 +320,23 @@ fun ContainerTerminalScreen( LoadingIndicator(size = LoadingSize.Medium) } } else { - tabs.forEach { tab -> + val selectedTabIndex = tabs.indexOfFirst { it.id == activeTabId }.coerceAtLeast(0) + var previousActiveIndex by remember { mutableIntStateOf(0) } + LaunchedEffect(selectedTabIndex) { + previousActiveIndex = selectedTabIndex + } + val isMovingRight = selectedTabIndex > previousActiveIndex + val hasMoved = selectedTabIndex != previousActiveIndex + + tabs.forEachIndexed { index, tab -> key(tab.id) { TerminalTabView( tab = tab, binder = binder!!, containerName = containerName, isVisible = tab.id == activeTabId, + isMovingRight = isMovingRight, + hasMoved = hasMoved, activity = activity, onSessionFinished = { closeTab(tab) }, modifier = Modifier.fillMaxSize() @@ -310,6 +354,8 @@ private fun TerminalTabView( binder: TerminalSessionService.SessionBinder, containerName: String, isVisible: Boolean, + isMovingRight: Boolean, + hasMoved: Boolean, activity: Activity?, onSessionFinished: () -> Unit, modifier: Modifier = Modifier, @@ -321,6 +367,29 @@ private fun TerminalTabView( val context = androidx.compose.ui.platform.LocalContext.current val terminalTypeface = remember { ResourcesCompat.getFont(context, R.font.jetbrains_mono) } + val slideOffsetFraction = 0.08f + val enterTransition = if (hasMoved) { + slideInHorizontally( + animationSpec = tween(durationMillis = 250, easing = FastOutSlowInEasing) + ) { width -> + if (isMovingRight) (width * slideOffsetFraction).toInt() + else -(width * slideOffsetFraction).toInt() + } + fadeIn(animationSpec = tween(250)) + } else { + fadeIn(animationSpec = AnimationUtils.fastSpec()) + } + + val exitTransition = if (hasMoved) { + slideOutHorizontally( + animationSpec = tween(durationMillis = 250, easing = FastOutSlowInEasing) + ) { width -> + if (isMovingRight) -(width * slideOffsetFraction).toInt() + else (width * slideOffsetFraction).toInt() + } + fadeOut(animationSpec = tween(250)) + } else { + fadeOut(animationSpec = AnimationUtils.fastSpec()) + } + // Terminal-only dark mode: renders the terminal page dark even when the rest // of the app follows the light theme. Read once at entry; re-enter to apply. val terminalDarkTheme = remember { @@ -339,8 +408,8 @@ private fun TerminalTabView( AnimatedVisibility( visible = isVisible, - enter = fadeIn(animationSpec = AnimationUtils.fastSpec()), - exit = fadeOut(animationSpec = AnimationUtils.fastSpec()), + enter = enterTransition, + exit = exitTransition, modifier = modifier ) { Column(modifier = Modifier.fillMaxSize()) { @@ -567,6 +636,52 @@ private fun UserPickerDialog( } } +@Composable +private fun CloseSessionDialog( + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val dialogShape = RoundedCornerShape(28.dp) + + androidx.compose.ui.window.Dialog( + onDismissRequest = onDismiss, + properties = androidx.compose.ui.window.DialogProperties(usePlatformDefaultWidth = false) + ) { + Surface( + modifier = Modifier + .fillMaxWidth(0.92f) + .wrapContentHeight(), + shape = dialogShape, + color = MaterialTheme.colorScheme.surfaceContainer, + border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), + tonalElevation = 0.dp + ) { + Column( + modifier = Modifier.padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = "Close this session?", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + + DialogFooterRow( + dismissLabel = context.getString(android.R.string.no), + confirmLabel = context.getString(android.R.string.yes), + onDismiss = onDismiss, + onConfirm = onConfirm, + confirmEnabled = true, + confirmColor = MaterialTheme.colorScheme.error, + confirmContentColor = MaterialTheme.colorScheme.onError + ) + } + } + } +} + private val VIRTUAL_KEYS_LAYOUT = """ [ [ diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainersScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainersScreen.kt index 3fdf6f2e..411ad450 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainersScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainersScreen.kt @@ -45,6 +45,7 @@ import com.droidspaces.app.util.PreferencesManager import com.droidspaces.app.util.FilePickerUtils import com.droidspaces.app.ui.component.ContainerCard import com.droidspaces.app.ui.component.ContainerCardActions +import com.droidspaces.app.ui.component.DialogFooterRow import com.droidspaces.app.ui.component.TerminalDialog import com.droidspaces.app.ui.component.EmptyState import com.droidspaces.app.ui.component.ErrorState @@ -84,6 +85,7 @@ fun ContainersScreen( // UI-only state (dialog triggers / pending pickers). var showUninstallConfirmation by remember { mutableStateOf(null) } + var showStopConfirmationFor by remember { mutableStateOf(null) } var pendingSparseOperation by remember { mutableStateOf(null) } var pendingExportContainer by remember { mutableStateOf(null) } var showRepoSheet by remember { mutableStateOf(false) } @@ -198,15 +200,8 @@ fun ContainersScreen( } }, onStop = { - scope.launch { - opsViewModel.executeOperation( - container, "stop", - onRefresh = { containerViewModel.refresh() }, - onClearUsage = { systemStatsViewModel.clearContainerUsage(it) }, - onFailureSnackbar = { msg -> scope.launch { snackbarHostState.showSnackbar(msg, duration = SnackbarDuration.Long) } } - ) - } - }, + showStopConfirmationFor = container + }, onRestart = { scope.launch { opsViewModel.executeOperation( @@ -367,6 +362,27 @@ fun ContainersScreen( ) } + // Stop confirmation dialog + showStopConfirmationFor?.let { container -> + StopContainerConfirmationDialog( + containerName = container.name, + onConfirm = { + showStopConfirmationFor = null + scope.launch { + opsViewModel.executeOperation( + container, "stop", + onRefresh = { containerViewModel.refresh() }, + onClearUsage = { systemStatsViewModel.clearContainerUsage(it) }, + onFailureSnackbar = { msg -> scope.launch { snackbarHostState.showSnackbar(msg, duration = SnackbarDuration.Long) } } + ) + } + }, + onDismiss = { + showStopConfirmationFor = null + } + ) + } + // Uninstall progress dialog (opsViewModel.uninstallState as? UninstallState.InProgress)?.let { state -> ProgressDialog( @@ -432,8 +448,8 @@ private fun SparseSizeDialog( ) { Surface( modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp) + .fillMaxWidth(0.92f) + .wrapContentHeight() .imePadding(), shape = dialogShape, color = MaterialTheme.colorScheme.surfaceContainer, @@ -462,37 +478,13 @@ private fun SparseSizeDialog( ) ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Surface( - modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable(onClick = onDismiss), - shape = RoundedCornerShape(14.dp), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f), - border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), - tonalElevation = 0.dp - ) { - Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) { - Text(context.getString(R.string.cancel), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold) - } - } - Surface( - modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable(enabled = isValid, onClick = { size?.let { onConfirm(it) } }), - shape = RoundedCornerShape(14.dp), - color = if (isValid) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), - tonalElevation = 0.dp - ) { - Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) { - Text( - context.getString(R.string.continue_button), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - color = if (isValid) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - ) - } - } - } + DialogFooterRow( + dismissLabel = context.getString(R.string.cancel), + confirmLabel = context.getString(R.string.continue_button), + onDismiss = onDismiss, + onConfirm = { size?.let { onConfirm(it) } }, + confirmEnabled = isValid + ) } } } @@ -515,8 +507,8 @@ private fun UninstallConfirmationDialog( ) { Surface( modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp), + .fillMaxWidth(0.92f) + .wrapContentHeight(), shape = dialogShape, color = MaterialTheme.colorScheme.surfaceContainer, border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.3f)), @@ -570,37 +562,73 @@ private fun UninstallConfirmationDialog( ) ) } + DialogFooterRow( + dismissLabel = context.getString(R.string.cancel), + confirmLabel = context.getString(R.string.uninstall), + onDismiss = onDismiss, + onConfirm = onConfirm, + confirmEnabled = isConfirmed, + confirmColor = MaterialTheme.colorScheme.error, + confirmContentColor = MaterialTheme.colorScheme.onError + ) + } + } + } +} + +@Composable +private fun StopContainerConfirmationDialog( + containerName: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + val context = LocalContext.current + val dialogShape = RoundedCornerShape(24.dp) + + Dialog( + onDismissRequest = onDismiss, + properties = androidx.compose.ui.window.DialogProperties(usePlatformDefaultWidth = false) + ) { + Surface( + modifier = Modifier + .fillMaxWidth(0.92f) + .wrapContentHeight(), + shape = dialogShape, + color = MaterialTheme.colorScheme.surfaceContainer, + border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f)), + tonalElevation = 0.dp + ) { + Column(modifier = Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { Row( - modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - Surface( - modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable(onClick = onDismiss), - shape = RoundedCornerShape(14.dp), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f), - border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), - tonalElevation = 0.dp - ) { - Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) { - Text(context.getString(R.string.cancel), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold) - } - } - Surface( - modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable(enabled = isConfirmed, onClick = onConfirm), - shape = RoundedCornerShape(14.dp), - color = if (isConfirmed) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), - tonalElevation = 0.dp - ) { - Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) { - Text( - context.getString(R.string.uninstall), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.Bold, - color = if (isConfirmed) MaterialTheme.colorScheme.onError else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - ) - } - } + Icon( + imageVector = androidx.compose.material.icons.Icons.Default.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(24.dp) + ) + Text( + text = "Stop Container?", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) } + Text( + text = "Are you sure you want to stop the container \"$containerName\"?", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + DialogFooterRow( + dismissLabel = context.getString(android.R.string.no), + confirmLabel = context.getString(android.R.string.yes), + onDismiss = onDismiss, + onConfirm = onConfirm, + confirmEnabled = true, + confirmColor = MaterialTheme.colorScheme.error, + confirmContentColor = MaterialTheme.colorScheme.onError + ) } } } diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/MainTabScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/MainTabScreen.kt index a33159b2..448ba47f 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/MainTabScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/MainTabScreen.kt @@ -40,6 +40,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import androidx.compose.ui.unit.sp import com.droidspaces.app.R +import androidx.compose.ui.graphics.lerp as colorLerp +import androidx.compose.ui.unit.lerp as dpLerp enum class TabItem(val titleResId: Int, val icon: androidx.compose.ui.graphics.vector.ImageVector) { Home(R.string.home_title, Icons.Default.Home), @@ -355,7 +357,8 @@ fun MainTabScreen( .onSizeChanged { bottomBarHeight = with(density) { it.height.toDp() } } ) { MainBottomBar( - selectedTab = selectedTab, + pagerState = pagerState, + tabs = tabs, onTabSelected = { tab -> scope.launch { pagerState.scrollToPage(tabs.indexOf(tab)) @@ -574,14 +577,15 @@ private fun ControlPanelTabContent( } } +@OptIn(ExperimentalFoundationApi::class) @Composable private fun MainBottomBar( - selectedTab: TabItem, + pagerState: androidx.compose.foundation.pager.PagerState, + tabs: Array, onTabSelected: (TabItem) -> Unit ) { val context = LocalContext.current - val tabs = TabItem.entries - val selectedIndex = tabs.indexOf(selectedTab) + val pagerPosition = pagerState.currentPage + pagerState.currentPageOffsetFraction Surface( modifier = Modifier.fillMaxWidth(), @@ -603,14 +607,7 @@ private fun MainBottomBar( // Background Indicator BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { val tabWidth = maxWidth / tabs.size - val offset by androidx.compose.animation.core.animateDpAsState( - targetValue = tabWidth * selectedIndex, - animationSpec = androidx.compose.animation.core.spring( - dampingRatio = androidx.compose.animation.core.Spring.DampingRatioLowBouncy, - stiffness = androidx.compose.animation.core.Spring.StiffnessLow - ), - label = "IndicatorOffset" - ) + val offset = tabWidth * pagerPosition Surface( modifier = Modifier @@ -627,12 +624,22 @@ private fun MainBottomBar( horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically ) { - tabs.forEach { tab -> - val isSelected = selectedTab == tab - val contentColor by androidx.compose.animation.animateColorAsState( - targetValue = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), - label = "IconColor" - ) + tabs.forEachIndexed { index, tab -> + val distance = if (pagerPosition > index) pagerPosition - index else index.toFloat() - pagerPosition + val selectionFraction = (1.0f - distance).coerceIn(0.0f, 1.0f) + + val unselectedColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + val selectedColor = MaterialTheme.colorScheme.primary + val contentColor = colorLerp(unselectedColor, selectedColor, selectionFraction) + + val iconSize = dpLerp(22.dp, 24.dp, selectionFraction) + val fontSize = (10f + 1f * selectionFraction).sp + val isSelected = pagerState.currentPage == index + val fontWeight = if (isSelected) { + FontWeight.Bold + } else { + FontWeight((400f + 300f * selectionFraction).toInt().coerceIn(400, 700)) + } Surface( onClick = { onTabSelected(tab) }, @@ -650,15 +657,15 @@ private fun MainBottomBar( Icon( imageVector = tab.icon, contentDescription = null, - modifier = Modifier.size(if (isSelected) 24.dp else 22.dp), + modifier = Modifier.size(iconSize), tint = contentColor ) Text( text = context.getString(tab.titleResId), style = MaterialTheme.typography.labelSmall, color = contentColor, - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, - fontSize = if (isSelected) 11.sp else 10.sp + fontSize = fontSize, + fontWeight = fontWeight ) } }