From ebfd1ea97eff0a77ae02985aa1f4da516e1693a0 Mon Sep 17 00:00:00 2001 From: VizXtreme Date: Mon, 3 Aug 2026 00:44:42 +0530 Subject: [PATCH 1/4] UI: updated spinner and PullToRefresh anim, corner radius in systemd page, and minor tweaks --- .../app/ui/component/AccentColorPicker.kt | 5 +- .../app/ui/component/FilePickerDialog.kt | 4 +- .../app/ui/component/PullToRefreshWrapper.kt | 71 +- .../app/ui/component/RootfsRepoSheet.kt | 4 +- .../ui/navigation/DroidspacesNavigation.kt | 2 +- .../app/ui/screen/ContainerTerminalScreen.kt | 4 +- .../app/ui/screen/InitServiceScreen.kt | 2 +- .../app/ui/screen/RequirementsScreen.kt | 4 +- .../app/ui/util/LoadingIndicator.kt | 693 +++++++++++++++++- 9 files changed, 756 insertions(+), 33 deletions(-) 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/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/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..650520ea 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 @@ -36,6 +36,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 +219,7 @@ private fun RepoLoadingContent() { .height(240.dp), contentAlignment = Alignment.Center ) { - CircularProgressIndicator() + LoadingIndicator(size = LoadingSize.Medium) } } 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/ContainerTerminalScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerTerminalScreen.kt index ff996bb1..939b022e 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 @@ -41,6 +41,8 @@ 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.termux.terminal.TerminalSession import com.termux.view.TerminalView import java.lang.ref.WeakReference @@ -279,7 +281,7 @@ fun ContainerTerminalScreen( ) { if (binder == null || tabs.isEmpty()) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator() + LoadingIndicator(size = LoadingSize.Medium) } } else { tabs.forEach { tab -> 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/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 From ca258654b238a1902b171f810fbe920702ef8edc Mon Sep 17 00:00:00 2001 From: VizXtreme Date: Mon, 3 Aug 2026 09:21:21 +0530 Subject: [PATCH 2/4] UI: restore graphics-shapes dependency to resolve compilation failure --- Android/app/build.gradle.kts | 1 + 1 file changed, 1 insertion(+) 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") From b9576357fbfbba393ba2cda9d53434a8bee4077f Mon Sep 17 00:00:00 2001 From: VizXtreme Date: Mon, 3 Aug 2026 09:21:50 +0530 Subject: [PATCH 3/4] docs: add changelog.md for ui branch --- changelog.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 changelog.md diff --git a/changelog.md b/changelog.md new file mode 100644 index 00000000..6cfab7b2 --- /dev/null +++ b/changelog.md @@ -0,0 +1,17 @@ +# Changelog + +## Commits on `ui` branch: +* UI: restore graphics-shapes dependency to resolve compilation failure (ca25865) +* UI: updated spinner and PullToRefresh anim, corner radius in systemd page, and minor tweaks (ebfd1ea) + +## Original commits (before squash): +* ci: skip Droidspaces CI on ui branch; drop --configuration-cache from android-fast (52ab8ee) +* fix(build): make generateModuleProp compatible with configuration cache (9088436) +* fix(ui): smooth pull-to-refresh release animation (5935fe8) +* added faster workflow (d81fa70) +* UI: migrate swipe down to refresh to shape-morphing LoadingIndicator (8ed20c8) +* UI: import LoadingSize in files using LoadingIndicator (e188639) +* UI: fix compilation errors (overload resolution ambiguity and layout imports) (881f354) +* UI: consolidate shape-morphing Google M3 LoadingIndicator APIs into LoadingIndicator.kt (f36f394) +* UI: replace circular progress indicators with shape-morphing Google MD3 LoadingIndicator (ac8d5fe) +* UI: match corner radius, disable color horizontal swipe, increase bottom padding (0624e65) From ba3e8f3ac092af6a62548fe7a97ed928acf53bf9 Mon Sep 17 00:00:00 2001 From: VizXtreme Date: Mon, 3 Aug 2026 09:34:06 +0530 Subject: [PATCH 4/4] Delete changelog.md --- changelog.md | 17 ----------------- 1 file changed, 17 deletions(-) delete mode 100644 changelog.md diff --git a/changelog.md b/changelog.md deleted file mode 100644 index 6cfab7b2..00000000 --- a/changelog.md +++ /dev/null @@ -1,17 +0,0 @@ -# Changelog - -## Commits on `ui` branch: -* UI: restore graphics-shapes dependency to resolve compilation failure (ca25865) -* UI: updated spinner and PullToRefresh anim, corner radius in systemd page, and minor tweaks (ebfd1ea) - -## Original commits (before squash): -* ci: skip Droidspaces CI on ui branch; drop --configuration-cache from android-fast (52ab8ee) -* fix(build): make generateModuleProp compatible with configuration cache (9088436) -* fix(ui): smooth pull-to-refresh release animation (5935fe8) -* added faster workflow (d81fa70) -* UI: migrate swipe down to refresh to shape-morphing LoadingIndicator (8ed20c8) -* UI: import LoadingSize in files using LoadingIndicator (e188639) -* UI: fix compilation errors (overload resolution ambiguity and layout imports) (881f354) -* UI: consolidate shape-morphing Google M3 LoadingIndicator APIs into LoadingIndicator.kt (f36f394) -* UI: replace circular progress indicators with shape-morphing Google MD3 LoadingIndicator (ac8d5fe) -* UI: match corner radius, disable color horizontal swipe, increase bottom padding (0624e65)