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/build.gradle.kts b/Android/app/build.gradle.kts index dd768519..dda9a4da 100644 --- a/Android/app/build.gradle.kts +++ b/Android/app/build.gradle.kts @@ -302,6 +302,7 @@ dependencies { implementation("androidx.compose.ui:ui-tooling-preview") implementation("androidx.compose.material3:material3") implementation("androidx.compose.material:material-icons-extended") + implementation("androidx.graphics:graphics-shapes:1.0.1") // Core Android implementation("androidx.core:core-ktx:1.12.0") diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/AccentColorPicker.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/AccentColorPicker.kt index c5f30518..5abdde35 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/AccentColorPicker.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/AccentColorPicker.kt @@ -60,10 +60,11 @@ fun AccentColorPicker( LazyRow( modifier = Modifier .fillMaxWidth() - .padding(bottom = 8.dp), + .padding(bottom = 20.dp), contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, + userScrollEnabled = false ) { items( items = ThemePalette.entries.toList(), 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/FilePickerDialog.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/FilePickerDialog.kt index fec89fcf..b22eab2c 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/FilePickerDialog.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/FilePickerDialog.kt @@ -33,6 +33,8 @@ import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight +import com.droidspaces.app.ui.util.LoadingIndicator +import com.droidspaces.app.ui.util.LoadingSize import com.droidspaces.app.ui.theme.JetBrainsMono import com.droidspaces.app.R import androidx.compose.ui.text.input.ImeAction @@ -236,7 +238,7 @@ fun FilePickerDialog( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { - CircularProgressIndicator() + LoadingIndicator(size = LoadingSize.Medium) } } else { LazyColumn( 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/PullToRefreshWrapper.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/PullToRefreshWrapper.kt index 9de5be5e..fafab311 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/PullToRefreshWrapper.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/PullToRefreshWrapper.kt @@ -1,5 +1,8 @@ package com.droidspaces.app.ui.component +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring import androidx.compose.foundation.layout.* import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme @@ -8,8 +11,12 @@ import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.drawscope.rotate import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.unit.dp +import com.droidspaces.app.ui.util.LoadingIndicator import kotlinx.coroutines.delay /** @@ -20,6 +27,8 @@ import kotlinx.coroutines.delay * - Hardware-accelerated indicator with graphicsLayer * - Material You theming integration * - No redundant state management (removed unused triggerRefresh) + * - Smooth spring animation on release — indicator slides to resting + * position instead of teleporting (fixes the jump-on-release bug) * * Performance characteristics: * - 0 allocations in hot path @@ -54,6 +63,23 @@ fun PullToRefreshWrapper( } } + // Smooth spring animation for the indicator's vertical position. + // + // M3's PullToRefreshContainer internally uses an Animatable for verticalOffset, + // but in some BOM versions the Animatable snaps (instead of animating) when + // isRefreshing flips to true — causing the visible "jump". We work around this + // by reading state.verticalOffset and re-applying it through animateFloatAsState + // with a spring so the transition from any pull distance to the resting position + // is always a smooth slide. + val animatedOffset by animateFloatAsState( + targetValue = pullToRefreshState.verticalOffset, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium + ), + label = "pullToRefreshOffset" + ) + Box( modifier = modifier .fillMaxSize() @@ -61,17 +87,56 @@ fun PullToRefreshWrapper( ) { content() - // Hardware-accelerated refresh indicator + // Hardware-accelerated refresh indicator with smooth release animation. + // We override the vertical position with our spring-animated offset so that + // releasing the pull always produces a smooth slide rather than a teleport. PullToRefreshContainer( state = pullToRefreshState, modifier = Modifier .align(Alignment.TopCenter) .graphicsLayer { - // Enable hardware layer for smooth 60fps animation + // Replace the container's own offset with our smooth animated value. + // The container positions itself at y=0 (top), so we shift it down + // by the animated offset to match where the finger dragged to, then + // let the spring bring it back to the resting position smoothly. + translationY = animatedOffset - pullToRefreshState.verticalOffset shadowElevation = 0f }, containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.primary + contentColor = MaterialTheme.colorScheme.primary, + indicator = { state -> + val progress = state.progress + val isRefreshing = state.isRefreshing + + Box( + modifier = Modifier.size(40.dp), + contentAlignment = Alignment.Center + ) { + if (isRefreshing) { + LoadingIndicator( + modifier = Modifier.size(24.dp), + color = MaterialTheme.colorScheme.primary + ) + } else { + LoadingIndicator( + progress = { progress }, + modifier = Modifier + .size(24.dp) + .drawWithContent { + if (progress > 1f) { + // Rotate the entire shape-morphing path as the pull continues past 1.0 + rotate(-(progress - 1) * 180) { + this@drawWithContent.drawContent() + } + } else { + drawContent() + } + }, + color = MaterialTheme.colorScheme.primary + ) + } + } + } ) } } 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 3b9f4d39..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 @@ -36,6 +37,8 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.droidspaces.app.R import com.droidspaces.app.ui.util.ClearFocusOnClickOutside import com.droidspaces.app.ui.util.FocusUtils +import com.droidspaces.app.ui.util.LoadingIndicator +import com.droidspaces.app.ui.util.LoadingSize import com.droidspaces.app.ui.viewmodel.AssetDownloadState import com.droidspaces.app.ui.viewmodel.RepoUiState import com.droidspaces.app.ui.viewmodel.RootfsRepoViewModel @@ -217,7 +220,7 @@ private fun RepoLoadingContent() { .height(240.dp), contentAlignment = Alignment.Center ) { - CircularProgressIndicator() + LoadingIndicator(size = LoadingSize.Medium) } } @@ -484,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/navigation/DroidspacesNavigation.kt b/Android/app/src/main/java/com/droidspaces/app/ui/navigation/DroidspacesNavigation.kt index 2c268434..595719fa 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/navigation/DroidspacesNavigation.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/navigation/DroidspacesNavigation.kt @@ -498,7 +498,7 @@ fun DroidspacesNavigation( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { - LoadingIndicator() + LoadingIndicator(size = LoadingSize.Medium) } } else { containerInfo?.let { container -> 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 ff996bb1..cb139a94 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 @@ -41,6 +49,9 @@ import com.droidspaces.app.ui.terminal.virtualkeys.VirtualKeysListener import com.droidspaces.app.ui.terminal.virtualkeys.VirtualKeysView import com.droidspaces.app.util.AnimationUtils import com.droidspaces.app.util.ContainerOSInfoManager +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 @@ -55,7 +66,7 @@ private data class TerminalTab( val label: String, ) -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class) @Composable fun ContainerTerminalScreen( containerName: String, @@ -94,6 +105,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) { @@ -188,10 +200,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, @@ -211,58 +236,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) + ) } } } @@ -279,16 +315,26 @@ fun ContainerTerminalScreen( ) { if (binder == null || tabs.isEmpty()) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator() + 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() @@ -306,6 +352,8 @@ private fun TerminalTabView( binder: TerminalSessionService.SessionBinder, containerName: String, isVisible: Boolean, + isMovingRight: Boolean, + hasMoved: Boolean, activity: Activity?, onSessionFinished: () -> Unit, modifier: Modifier = Modifier, @@ -318,10 +366,33 @@ 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()) + } + AnimatedVisibility( visible = isVisible, - enter = fadeIn(animationSpec = AnimationUtils.fastSpec()), - exit = fadeOut(animationSpec = AnimationUtils.fastSpec()), + enter = enterTransition, + exit = exitTransition, modifier = modifier ) { Column(modifier = Modifier.fillMaxSize()) { @@ -539,6 +610,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/InitServiceScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/InitServiceScreen.kt index 8ed199ee..a19ededa 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/InitServiceScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/InitServiceScreen.kt @@ -467,7 +467,7 @@ private fun InitServiceCard( Surface( modifier = Modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.surfaceContainerHigh, - shape = RoundedCornerShape(12.dp), + shape = RoundedCornerShape(20.dp), border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f)) ) { Row(modifier = Modifier.fillMaxWidth().padding(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp)) { 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 ) } } diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/RequirementsScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/RequirementsScreen.kt index b3f427d6..bfd619c9 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/RequirementsScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/RequirementsScreen.kt @@ -49,6 +49,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import androidx.compose.runtime.rememberCoroutineScope import com.droidspaces.app.ui.util.showSuccess +import com.droidspaces.app.ui.util.LoadingIndicator @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -522,9 +523,8 @@ private fun CheckRequirementsButton( horizontalArrangement = Arrangement.Center ) { if (isRunning) { - CircularProgressIndicator( + LoadingIndicator( modifier = Modifier.size(20.dp), - strokeWidth = 2.dp, color = MaterialTheme.colorScheme.onPrimary ) } else { diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/util/LoadingIndicator.kt b/Android/app/src/main/java/com/droidspaces/app/ui/util/LoadingIndicator.kt index c0a2bb16..70a2b126 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/util/LoadingIndicator.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/util/LoadingIndicator.kt @@ -1,15 +1,69 @@ package com.droidspaces.app.ui.util +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationEndReason +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background import androidx.compose.foundation.layout.* -import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.foundation.progressSemantics +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.geometry.center +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Matrix +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.drawscope.Fill +import androidx.compose.ui.graphics.drawscope.rotate +import androidx.compose.ui.platform.InfiniteAnimationPolicy +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.progressBarRangeInfo +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEach +import androidx.compose.ui.util.fastMap +import androidx.graphics.shapes.CornerRounding +import androidx.graphics.shapes.Cubic +import androidx.graphics.shapes.Morph +import androidx.graphics.shapes.RoundedPolygon +import androidx.graphics.shapes.TransformResult +import androidx.graphics.shapes.circle +import androidx.graphics.shapes.rectangle +import androidx.graphics.shapes.star +import kotlin.math.PI +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.max +import kotlin.math.min +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +// ── COMPATIBILITY & DROIDSPACES CONVENIENCE WRAAPERS ─────────────────────── /** * Standardized loading indicator sizes. @@ -21,32 +75,16 @@ enum class LoadingSize(val size: Dp, val strokeWidth: Dp) { } /** - * Standardized loading indicator component. + * Standardized loading indicator component (Convenience compatibility wrapper). */ @Composable fun LoadingIndicator( - size: LoadingSize = LoadingSize.Medium, + size: LoadingSize, modifier: Modifier = Modifier, - color: androidx.compose.ui.graphics.Color? = null + color: Color? = null ) { - CircularProgressIndicator( + LoadingIndicator( modifier = modifier.size(size.size), - strokeWidth = size.strokeWidth, - color = color ?: MaterialTheme.colorScheme.primary - ) -} - -/** - * Small loading indicator with custom modifier (for inline use). - */ -@Composable -fun LoadingIndicator( - modifier: Modifier, - color: androidx.compose.ui.graphics.Color? = null -) { - CircularProgressIndicator( - modifier = modifier, - strokeWidth = LoadingSize.Small.strokeWidth, color = color ?: MaterialTheme.colorScheme.primary ) } @@ -80,3 +118,616 @@ fun FullScreenLoading( } } +// ── OFFICIAL GOOGLE MATERIAL 3 EXPRESSIVE APIs ────────────────────────────── + +/** + * A Material Design loading indicator. + * + * This version of the loading indicator morphs between its [polygons] shapes by the value of its + * [progress]. + * + * @param progress the progress of this loading indicator, where 0.0 represents no progress and 1.0 + * represents full progress. Values outside of this range are coerced into the range. + * @param modifier the [Modifier] to be applied to this loading indicator + * @param color the loading indicator's color + * @param polygons a list of [RoundedPolygon]s for the sequence of shapes this loading indicator + * will morph between as it progresses from 0.0 to 1.0. + */ +@Composable +fun LoadingIndicator( + progress: () -> Float, + modifier: Modifier = Modifier, + color: Color = LoadingIndicatorDefaults.indicatorColor, + polygons: List = LoadingIndicatorDefaults.DeterminateIndicatorPolygons, +) { + LoadingIndicatorImpl( + progress = progress, + modifier = modifier, + containerColor = Color.Unspecified, + indicatorColor = color, + containerShape = LoadingIndicatorDefaults.containerShape, + indicatorPolygons = polygons, + ) +} + +/** + * A Material Design loading indicator. + * + * This version of the loading indicator animates and morphs between various shapes as long as the + * loading indicator is visible. + * + * @param modifier the [Modifier] to be applied to this loading indicator + * @param color the loading indicator's color + * @param polygons a list of [RoundedPolygon]s for the sequence of shapes this loading indicator + * will morph between. + */ +@Composable +fun LoadingIndicator( + modifier: Modifier = Modifier, + color: Color = LoadingIndicatorDefaults.indicatorColor, + polygons: List = LoadingIndicatorDefaults.IndeterminateIndicatorPolygons, +) { + LoadingIndicatorImpl( + modifier = modifier, + containerColor = Color.Unspecified, + indicatorColor = color, + containerShape = LoadingIndicatorDefaults.containerShape, + indicatorPolygons = polygons, + ) +} + +/** + * A Material Design contained loading indicator. + * + * This version of the loading indicator morphs between its [polygons] shapes by the value of its + * [progress]. The shapes in this variation are contained within a colored [containerShape]. + * + * @param progress the progress of this loading indicator, where 0.0 represents no progress and 1.0 + * represents full progress. Values outside of this range are coerced into the range. + * @param modifier the [Modifier] to be applied to this loading indicator + * @param containerColor the loading indicator's container color + * @param indicatorColor the loading indicator's color + * @param containerShape the loading indicator's container shape + * @param polygons a list of [RoundedPolygon]s for the sequence of shapes this loading indicator + * will morph between as it progresses from 0.0 to 1.0. + */ +@Composable +fun ContainedLoadingIndicator( + progress: () -> Float, + modifier: Modifier = Modifier, + containerColor: Color = LoadingIndicatorDefaults.containedContainerColor, + indicatorColor: Color = LoadingIndicatorDefaults.containedIndicatorColor, + containerShape: Shape = LoadingIndicatorDefaults.containerShape, + polygons: List = LoadingIndicatorDefaults.DeterminateIndicatorPolygons, +) { + LoadingIndicatorImpl( + progress = progress, + modifier = modifier, + containerColor = containerColor, + indicatorColor = indicatorColor, + containerShape = containerShape, + indicatorPolygons = polygons, + ) +} + +/** + * A Material Design contained loading indicator. + * + * This version of the loading indicator animates and morphs between various shapes as long as the + * loading indicator is visible. The shapes in this variation are contained within a colored + * [containerShape]. + * + * @param modifier the [Modifier] to be applied to this loading indicator + * @param containerColor the loading indicator's container color + * @param indicatorColor the loading indicator's color + * @param containerShape the loading indicator's container shape + * @param polygons a list of [RoundedPolygon]s for the sequence of shapes this loading indicator + * will morph between. + */ +@Composable +fun ContainedLoadingIndicator( + modifier: Modifier = Modifier, + containerColor: Color = LoadingIndicatorDefaults.containedContainerColor, + indicatorColor: Color = LoadingIndicatorDefaults.containedIndicatorColor, + containerShape: Shape = LoadingIndicatorDefaults.containerShape, + polygons: List = LoadingIndicatorDefaults.IndeterminateIndicatorPolygons, +) { + LoadingIndicatorImpl( + modifier = modifier, + containerColor = containerColor, + indicatorColor = indicatorColor, + containerShape = containerShape, + indicatorPolygons = polygons, + ) +} + +// ── INTERNAL IMPLEMENTATION DETAILS ───────────────────────────────────────── + +@Composable +private fun LoadingIndicatorImpl( + progress: () -> Float, + modifier: Modifier, + containerColor: Color, + indicatorColor: Color, + containerShape: Shape, + indicatorPolygons: List, +) { + require(indicatorPolygons.size > 1) { + "indicatorPolygons should have, at least, two RoundedPolygons" + } + val coercedProgress = { progress().coerceIn(0f, 1f) } + val path = remember { Path() } + val scaleMatrix = remember { Matrix() } + val morphSequence = remember(indicatorPolygons) { + morphSequence(polygons = indicatorPolygons, circularSequence = false) + } + val morphScaleFactor = remember(morphSequence) { + calculateScaleFactor(indicatorPolygons) * LoadingIndicatorDefaults.ActiveIndicatorScale + } + Box( + modifier = modifier + .semantics(mergeDescendants = true) { + progressBarRangeInfo = ProgressBarRangeInfo( + coercedProgress().takeUnless { it.isNaN() } ?: 0f, + 0f..1f, + ) + } + .size( + width = LoadingIndicatorDefaults.ContainerWidth, + height = LoadingIndicatorDefaults.ContainerHeight, + ) + .fillMaxSize() + .clip(containerShape) + .background(containerColor), + contentAlignment = Alignment.Center, + ) { + Spacer( + modifier = Modifier + .aspectRatio(ratio = 1f, matchHeightConstraintsFirst = true) + .drawWithContent { + val progressValue = coercedProgress() + val activeMorphIndex = (morphSequence.size * progressValue) + .toInt() + .coerceAtMost(morphSequence.size - 1) + val adjustedProgressValue = if (progressValue == 1f && activeMorphIndex == morphSequence.size - 1) { + 1f + } else { + (progressValue * morphSequence.size) % 1f + } + + val rotation = -progressValue * 180 + rotate(rotation) { + drawPath( + path = processPath( + path = morphSequence[activeMorphIndex].toPath( + progress = adjustedProgressValue, + path = path, + startAngle = 0, + ), + size = size, + scaleFactor = morphScaleFactor, + scaleMatrix = scaleMatrix, + ), + color = indicatorColor, + style = Fill, + ) + } + } + ) + } +} + +@Composable +private fun LoadingIndicatorImpl( + modifier: Modifier, + containerColor: Color, + indicatorColor: Color, + containerShape: Shape, + indicatorPolygons: List, +) { + require(indicatorPolygons.size > 1) { + "indicatorPolygons should have, at least, two RoundedPolygons" + } + val morphSequence = remember(indicatorPolygons) { + morphSequence(polygons = indicatorPolygons, circularSequence = true) + } + val shapesScaleFactor = remember(indicatorPolygons) { + calculateScaleFactor(indicatorPolygons) * LoadingIndicatorDefaults.ActiveIndicatorScale + } + val morphProgress = remember { Animatable(0f) } + var morphRotationTargetAngle by remember { mutableFloatStateOf(QuarterRotation) } + val globalRotation = remember { Animatable(0f) } + var currentMorphIndex by remember(indicatorPolygons) { mutableIntStateOf(0) } + + LaunchedEffect(indicatorPolygons) { + val morphAnimationBlock = { + launch { + val morphAnimationSpec = spring(dampingRatio = 0.6f, stiffness = 200f, visibilityThreshold = 0.1f) + while (true) { + val deferred = async { + val animationResult = morphProgress.animateTo( + targetValue = 1f, + animationSpec = morphAnimationSpec, + ) + if (animationResult.endReason == AnimationEndReason.Finished) { + currentMorphIndex = (currentMorphIndex + 1) % morphSequence.size + morphProgress.snapTo(0f) + morphRotationTargetAngle = (morphRotationTargetAngle + QuarterRotation) % FullRotation + } + } + delay(MorphIntervalMillis) + deferred.await() + } + } + } + + val rotationAnimationBlock = { + launch { + globalRotation.animateTo( + targetValue = FullRotation, + animationSpec = infiniteRepeatable( + tween(GlobalRotationDurationMillis, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + ) + } + } + + when (val policy = coroutineContext[InfiniteAnimationPolicy]) { + null -> { + morphAnimationBlock() + rotationAnimationBlock() + } + else -> policy.onInfiniteOperation { + morphAnimationBlock() + rotationAnimationBlock() + } + } + } + + val path = remember { Path() } + val scaleMatrix = remember { Matrix() } + Box( + modifier = modifier + .progressSemantics() + .size( + width = LoadingIndicatorDefaults.ContainerWidth, + height = LoadingIndicatorDefaults.ContainerHeight, + ) + .fillMaxSize() + .clip(containerShape) + .background(containerColor), + contentAlignment = Alignment.Center, + ) { + Spacer( + modifier = Modifier + .aspectRatio(1f, matchHeightConstraintsFirst = true) + .drawWithContent { + val progress = morphProgress.value + rotate(progress * 90 + morphRotationTargetAngle + globalRotation.value) { + drawPath( + path = processPath( + path = morphSequence[currentMorphIndex].toPath( + progress = progress, + path = path, + startAngle = 0, + ), + size = size, + scaleFactor = shapesScaleFactor, + scaleMatrix = scaleMatrix, + ), + color = indicatorColor, + style = Fill, + ) + } + } + ) + } +} + +// ── LOADING INDICATOR DEFAULTS ────────────────────────────────────────────── +object LoadingIndicatorDefaults { + val ContainerWidth: Dp = 48.dp + val ContainerHeight: Dp = 48.dp + val IndicatorSize: Dp = 40.dp + + val containerShape: Shape + @Composable get() = RoundedCornerShape(16.dp) + + val indicatorColor: Color + @Composable get() = MaterialTheme.colorScheme.primary + + val containedIndicatorColor: Color + @Composable get() = MaterialTheme.colorScheme.primary + + val containedContainerColor: Color + @Composable get() = MaterialTheme.colorScheme.surfaceContainerHigh + + val IndeterminateIndicatorPolygons: List = listOf( + MaterialShapes.SoftBurst, + MaterialShapes.Cookie9Sided, + MaterialShapes.Pentagon, + MaterialShapes.Pill, + MaterialShapes.Sunny, + MaterialShapes.Cookie4Sided, + MaterialShapes.Oval, + ) + + val DeterminateIndicatorPolygons: List = listOf( + MaterialShapes.Circle.transformed(Matrix().apply { rotateZ(360f / 20) }), + MaterialShapes.SoftBurst, + ) + + internal val ActiveIndicatorScale = + IndicatorSize.value / min(ContainerWidth.value, ContainerHeight.value) +} + +// ── SHAPE UTIL EXTENSIONS (TRANSFORM & PATH CONVERSION) ──────────────────── +internal fun RoundedPolygon.transformed(matrix: Matrix): RoundedPolygon = transformed { x, y -> + val transformedPoint = matrix.map(Offset(x, y)) + TransformResult(transformedPoint.x, transformedPoint.y) +} + +internal fun Morph.toPath( + progress: Float, + path: Path = Path(), + startAngle: Int = 270, + repeatPath: Boolean = false, + closePath: Boolean = true, + rotationPivotX: Float = 0f, + rotationPivotY: Float = 0f, +): Path { + pathFromCubics( + path = path, + startAngle = startAngle, + repeatPath = repeatPath, + closePath = closePath, + cubics = asCubics(progress), + rotationPivotX = rotationPivotX, + rotationPivotY = rotationPivotY, + ) + return path +} + +private fun pathFromCubics( + path: Path, + startAngle: Int, + repeatPath: Boolean, + closePath: Boolean, + cubics: List, + rotationPivotX: Float, + rotationPivotY: Float, +) { + var first = true + var firstCubic: Cubic? = null + path.rewind() + cubics.fastForEach { + if (first) { + path.moveTo(it.anchor0X, it.anchor0Y) + if (startAngle != 0) { + firstCubic = it + } + first = false + } + path.cubicTo( + it.control0X, + it.control0Y, + it.control1X, + it.control1Y, + it.anchor1X, + it.anchor1Y, + ) + } + if (repeatPath) { + var firstInRepeat = true + cubics.fastForEach { + if (firstInRepeat) { + path.lineTo(it.anchor0X, it.anchor0Y) + firstInRepeat = false + } + path.cubicTo( + it.control0X, + it.control0Y, + it.control1X, + it.control1Y, + it.anchor1X, + it.anchor1Y, + ) + } + } + + if (closePath) path.close() + + if (startAngle != 0 && firstCubic != null) { + val angleToFirstCubic = radiansToDegrees( + atan2( + y = cubics[0].anchor0Y - rotationPivotY, + x = cubics[0].anchor0X - rotationPivotX, + ) + ) + path.transform(Matrix().apply { rotateZ(-angleToFirstCubic + startAngle) }) + } +} + +private fun radiansToDegrees(radians: Float): Float { + return (radians * 180.0 / PI).toFloat() +} + +private fun morphSequence(polygons: List, circularSequence: Boolean): List { + return buildList { + for (i in polygons.indices) { + if (i + 1 < polygons.size) { + add(Morph(polygons[i].normalized(), polygons[i + 1].normalized())) + } else if (circularSequence) { + add(Morph(polygons[i].normalized(), polygons[0].normalized())) + } + } + } +} + +private fun calculateScaleFactor(indicatorPolygons: List): Float { + var scaleFactor = 1f + val bounds = FloatArray(size = 4) + val maxBounds = FloatArray(size = 4) + indicatorPolygons.fastForEach { polygon -> + polygon.calculateBounds(bounds) + polygon.calculateMaxBounds(maxBounds) + val scaleX = bounds.width() / maxBounds.width() + val scaleY = bounds.height() / maxBounds.height() + scaleFactor = min(scaleFactor, max(scaleX, scaleY)) + } + return scaleFactor +} + +private fun FloatArray.width(): Float = this[2] - this[0] +private fun FloatArray.height(): Float = this[3] - this[1] + +private fun processPath( + path: Path, + size: Size, + scaleFactor: Float, + scaleMatrix: Matrix = Matrix(), +): Path { + scaleMatrix.reset() + scaleMatrix.apply { scale(x = size.width * scaleFactor, y = size.height * scaleFactor) } + path.transform(scaleMatrix) + path.translate(size.center - path.getBounds().center) + return path +} + +// ── PREDEFINED MATERIAL SHAPES ────────────────────────────────────────────── +object MaterialShapes { + private val cornerRound15 = CornerRounding(radius = .15f) + private val cornerRound20 = CornerRounding(radius = .2f) + private val cornerRound30 = CornerRounding(radius = .3f) + private val cornerRound50 = CornerRounding(radius = .5f) + private val cornerRound100 = CornerRounding(radius = 1f) + + private val rotateNeg45 = Matrix().apply { rotateZ(-45f) } + private val rotateNeg90 = Matrix().apply { rotateZ(-90f) } + private val rotateNeg135 = Matrix().apply { rotateZ(-135f) } + + val Circle: RoundedPolygon = RoundedPolygon.circle(numVertices = 10).normalized() + val Square: RoundedPolygon = RoundedPolygon.rectangle(width = 1f, height = 1f, rounding = cornerRound30).normalized() + val Oval: RoundedPolygon = RoundedPolygon.circle().transformed(Matrix().apply { scale(1f, 0.64f) }).transformed(rotateNeg45).normalized() + + val Pill: RoundedPolygon = customPolygon( + listOf( + PointNRound(Offset(0.961f, 0.039f), CornerRounding(0.426f)), + PointNRound(Offset(1.001f, 0.428f)), + PointNRound(Offset(1.000f, 0.609f), CornerRounding(1.000f)), + ), + reps = 2, + mirroring = true, + ).normalized() + + val Pentagon: RoundedPolygon = customPolygon( + listOf( + PointNRound(Offset(0.500f, -0.009f), CornerRounding(0.172f)), + PointNRound(Offset(1.030f, 0.365f), CornerRounding(0.164f)), + PointNRound(Offset(0.828f, 0.970f), CornerRounding(0.169f)), + ), + reps = 1, + mirroring = true, + ).normalized() + + val Sunny: RoundedPolygon = RoundedPolygon.star( + numVerticesPerRadius = 8, + innerRadius = .8f, + rounding = cornerRound15, + ).normalized() + + val Cookie4Sided: RoundedPolygon = customPolygon( + listOf( + PointNRound(Offset(1.237f, 1.236f), CornerRounding(0.258f)), + PointNRound(Offset(0.500f, 0.918f), CornerRounding(0.233f)), + ), + 4, + ).normalized() + + val Cookie9Sided: RoundedPolygon = RoundedPolygon.star( + numVerticesPerRadius = 9, + innerRadius = .8f, + rounding = cornerRound50, + ).transformed(rotateNeg90).normalized() + + val SoftBurst: RoundedPolygon = customPolygon( + listOf( + PointNRound(Offset(0.193f, 0.277f), CornerRounding(0.053f)), + PointNRound(Offset(0.176f, 0.055f), CornerRounding(0.053f)), + ), + reps = 10, + ).normalized() + + private data class PointNRound( + val o: Offset, + val r: CornerRounding = CornerRounding.Unrounded, + ) + + private fun doRepeat( + points: List, + reps: Int, + center: Offset, + mirroring: Boolean, + ) = if (mirroring) { + buildList { + val angles = points.fastMap { (it.o - center).angleDegrees() } + val distances = points.fastMap { (it.o - center).getDistance() } + val actualReps = reps * 2 + val sectionAngle = 360f / actualReps + repeat(actualReps) { + points.indices.forEach { index -> + val i = if (it % 2 == 0) index else points.lastIndex - index + if (i > 0 || it % 2 == 0) { + val a = (sectionAngle * it + + if (it % 2 == 0) angles[i] + else sectionAngle - angles[i] + 2 * angles[0]) + .toRadians() + val finalPoint = Offset(cos(a), sin(a)) * distances[i] + center + add(PointNRound(finalPoint, points[i].r)) + } + } + } + } + } else { + points.size.let { np -> + (0 until np * reps).map { + val point = points[it % np].o.rotateDegrees((it / np) * 360f / reps, center) + PointNRound(point, points[it % np].r) + } + } + } + + private fun Offset.rotateDegrees(angle: Float, center: Offset = Offset.Zero) = + (angle.toRadians()).let { a -> + val off = this - center + Offset(off.x * cos(a) - off.y * sin(a), off.x * sin(a) + off.y * cos(a)) + center + } + + private fun Float.toRadians(): Float = this / 360f * 2 * PI.toFloat() + + private fun Offset.angleDegrees() = atan2(y, x) * 180f / PI.toFloat() + + private fun customPolygon( + pnr: List, + reps: Int, + center: Offset = Offset(0.5f, 0.5f), + mirroring: Boolean = false, + ): RoundedPolygon { + val actualPoints = doRepeat(pnr, reps, center, mirroring) + return RoundedPolygon( + vertices = FloatArray(actualPoints.size * 2) { ix -> + actualPoints[ix / 2].o.let { if (ix % 2 == 0) it.x else it.y } + }, + perVertexRounding = buildList { for (p in actualPoints) add(p.r) }, + centerX = center.x, + centerY = center.y, + ) + } +} + +private const val GlobalRotationDurationMillis = 4666 +private const val MorphIntervalMillis = 650L + +private const val FullRotation = 360f +private const val QuarterRotation = FullRotation / 4f