diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/AlwaysOnDisplay.kt b/app/src/main/java/org/nsh07/pomodoro/ui/AlwaysOnDisplay.kt new file mode 100644 index 0000000..e96d4dc --- /dev/null +++ b/app/src/main/java/org/nsh07/pomodoro/ui/AlwaysOnDisplay.kt @@ -0,0 +1,196 @@ +/* + * Copyright (c) 2025 Nishant Mishra + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.nsh07.pomodoro.ui + +import android.app.Activity +import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.CircularWavyProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.MaterialTheme.motionScheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import androidx.navigation3.ui.LocalNavAnimatedContentScope +import kotlinx.coroutines.delay +import org.nsh07.pomodoro.ui.theme.AppFonts.openRundeClock +import org.nsh07.pomodoro.ui.theme.TomatoTheme +import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerMode +import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerState + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun SharedTransitionScope.AlwaysOnDisplay( + timerState: TimerState, + progress: () -> Float, + modifier: Modifier = Modifier +) { + var sharedElementTransitionComplete by remember { mutableStateOf(false) } + + val view = LocalView.current + val window = remember { (view.context as Activity).window } + val insetsController = remember { WindowCompat.getInsetsController(window, view) } + + DisposableEffect(Unit) { + insetsController.apply { + hide(WindowInsetsCompat.Type.statusBars()) + hide(WindowInsetsCompat.Type.navigationBars()) + systemBarsBehavior = + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } + onDispose { + insetsController.apply { + show(WindowInsetsCompat.Type.statusBars()) + show(WindowInsetsCompat.Type.navigationBars()) + systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_DEFAULT + } + } + } + + LaunchedEffect(Unit) { + delay(300) + sharedElementTransitionComplete = true + } + + val primary by animateColorAsState( + if (sharedElementTransitionComplete) Color(0xFFA2A2A2) + else { + if (timerState.timerMode == TimerMode.FOCUS) colorScheme.primary + else colorScheme.tertiary + }, + animationSpec = motionScheme.slowEffectsSpec() + ) + val secondaryContainer by animateColorAsState( + if (sharedElementTransitionComplete) Color(0xFF1D1D1D) + else { + if (timerState.timerMode == TimerMode.FOCUS) colorScheme.secondaryContainer + else colorScheme.tertiaryContainer + }, + animationSpec = motionScheme.slowEffectsSpec() + ) + val surface by animateColorAsState( + if (sharedElementTransitionComplete) Color.Black + else colorScheme.surface, + animationSpec = motionScheme.slowEffectsSpec() + ) + val onSurface by animateColorAsState( + if (sharedElementTransitionComplete) Color(0xFFE3E3E3) + else colorScheme.onSurface, + animationSpec = motionScheme.slowEffectsSpec() + ) + + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .fillMaxSize() + .background(surface) + ) { + if (timerState.timerMode == TimerMode.FOCUS) { + CircularProgressIndicator( + progress = progress, + modifier = Modifier + .sharedBounds( + sharedContentState = this@AlwaysOnDisplay.rememberSharedContentState("focus progress"), + animatedVisibilityScope = LocalNavAnimatedContentScope.current + ) + .size(250.dp), + color = primary, + trackColor = secondaryContainer, + strokeWidth = 12.dp, + gapSize = 8.dp, + ) + } else { + CircularWavyProgressIndicator( + progress = progress, + modifier = Modifier + .sharedBounds( + sharedContentState = this@AlwaysOnDisplay.rememberSharedContentState("break progress"), + animatedVisibilityScope = LocalNavAnimatedContentScope.current + ) + .size(250.dp), + color = primary, + trackColor = secondaryContainer, + stroke = Stroke( + width = with(LocalDensity.current) { + 12.dp.toPx() + }, + cap = StrokeCap.Round, + ), + trackStroke = Stroke( + width = with(LocalDensity.current) { + 12.dp.toPx() + }, + cap = StrokeCap.Round, + ), + wavelength = 94.dp, + gapSize = 8.dp + ) + } + + Text( + text = timerState.timeStr, + style = TextStyle( + fontFamily = openRundeClock, + fontWeight = FontWeight.Bold, + fontSize = 56.sp, + letterSpacing = (-2).sp + ), + textAlign = TextAlign.Center, + color = onSurface, + maxLines = 1, + modifier = Modifier.sharedBounds( + sharedContentState = this@AlwaysOnDisplay.rememberSharedContentState("clock"), + animatedVisibilityScope = LocalNavAnimatedContentScope.current + ) + ) + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Preview +@Composable +private fun AlwaysOnDisplayPreview() { + val timerState = TimerState() + val progress = { 0.5f } + TomatoTheme { + SharedTransitionLayout { + AlwaysOnDisplay( + timerState = timerState, + progress = progress + ) + } + } +} diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/AppScreen.kt b/app/src/main/java/org/nsh07/pomodoro/ui/AppScreen.kt index 4bbdc6e..b26212e 100644 --- a/app/src/main/java/org/nsh07/pomodoro/ui/AppScreen.kt +++ b/app/src/main/java/org/nsh07/pomodoro/ui/AppScreen.kt @@ -8,17 +8,16 @@ package org.nsh07.pomodoro.ui import android.content.Intent -import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ContentTransform import androidx.compose.animation.Crossfade +import androidx.compose.animation.SharedTransitionLayout import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleOut -import androidx.compose.animation.togetherWith import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.calculateStartPadding -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @@ -29,17 +28,13 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.ShortNavigationBar import androidx.compose.material3.ShortNavigationBarArrangement import androidx.compose.material3.ShortNavigationBarItem -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.painterResource @@ -78,7 +73,6 @@ fun AppScreen( val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass val backStack = rememberNavBackStack(Screen.Timer) - var showAOD by remember { mutableStateOf(false) } if (uiState.alarmRinging) AlarmDialog { @@ -88,147 +82,159 @@ fun AppScreen( } } - AnimatedContent( - showAOD, - transitionSpec = { fadeIn().togetherWith(fadeOut()) } - ) { aod -> - if (!aod) { - Scaffold( - bottomBar = { - val wide = remember { - windowSizeClass.isWidthAtLeastBreakpoint( - WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND - ) - } - ShortNavigationBar( - arrangement = - if (wide) ShortNavigationBarArrangement.Centered - else ShortNavigationBarArrangement.EqualWeight - ) { - screens.forEach { - val selected = backStack.last() == it.route - ShortNavigationBarItem( - selected = selected, - onClick = if (it.route != Screen.Timer) { // Ensure the backstack does not accumulate screens - { - if (backStack.size < 2) backStack.add(it.route) - else backStack[1] = it.route - } - } else { - { if (backStack.size > 1) backStack.removeAt(1) } - }, - icon = { - Crossfade(selected) { selected -> - if (selected) Icon(painterResource(it.selectedIcon), null) - else Icon(painterResource(it.unselectedIcon), null) - } - }, - iconPosition = - if (wide) NavigationItemIconPosition.Start - else NavigationItemIconPosition.Top, - label = { Text(stringResource(it.label)) } - ) - } - } - }, - modifier = Modifier - .then( - if (isAODEnabled) Modifier.clickable { showAOD = !showAOD } - else Modifier + + Scaffold( + bottomBar = { + AnimatedVisibility( + backStack.last() !is Screen.AOD, + enter = fadeIn(), + exit = fadeOut() + ) { + val wide = remember { + windowSizeClass.isWidthAtLeastBreakpoint( + WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND ) - ) { contentPadding -> - NavDisplay( - backStack = backStack, - onBack = { backStack.removeLastOrNull() }, - transitionSpec = { - ContentTransform( - fadeIn(motionScheme.defaultEffectsSpec()), - fadeOut(motionScheme.defaultEffectsSpec()) + } + ShortNavigationBar( + arrangement = + if (wide) ShortNavigationBarArrangement.Centered + else ShortNavigationBarArrangement.EqualWeight + ) { + screens.forEach { + val selected = backStack.last() == it.route + ShortNavigationBarItem( + selected = selected, + onClick = if (it.route != Screen.Timer) { // Ensure the backstack does not accumulate screens + { + if (backStack.size < 2) backStack.add(it.route) + else backStack[1] = it.route + } + } else { + { if (backStack.size > 1) backStack.removeAt(1) } + }, + icon = { + Crossfade(selected) { selected -> + if (selected) Icon(painterResource(it.selectedIcon), null) + else Icon(painterResource(it.unselectedIcon), null) + } + }, + iconPosition = + if (wide) NavigationItemIconPosition.Start + else NavigationItemIconPosition.Top, + label = { Text(stringResource(it.label)) } ) - }, - popTransitionSpec = { - ContentTransform( - fadeIn(motionScheme.defaultEffectsSpec()), - fadeOut(motionScheme.defaultEffectsSpec()) - ) - }, - predictivePopTransitionSpec = { - ContentTransform( - fadeIn(motionScheme.defaultEffectsSpec()), - fadeOut(motionScheme.defaultEffectsSpec()) + - scaleOut(targetScale = 0.7f), - ) - }, - entryProvider = entryProvider { - entry { - TimerScreen( - timerState = uiState, - progress = { progress }, - onAction = { action -> - when (action) { - TimerAction.ResetTimer -> - Intent(context, TimerService::class.java).also { - it.action = TimerService.Actions.RESET.toString() - context.startService(it) - } - - is TimerAction.SkipTimer -> - Intent(context, TimerService::class.java).also { - it.action = TimerService.Actions.SKIP.toString() - context.startService(it) - } - - TimerAction.StopAlarm -> - Intent(context, TimerService::class.java).also { - it.action = - TimerService.Actions.STOP_ALARM.toString() - context.startService(it) - } - - TimerAction.ToggleTimer -> - Intent(context, TimerService::class.java).also { - it.action = TimerService.Actions.TOGGLE.toString() - context.startService(it) - } - } - }, - modifier = modifier.padding( - start = contentPadding.calculateStartPadding(layoutDirection), - end = contentPadding.calculateEndPadding(layoutDirection), - bottom = contentPadding.calculateBottomPadding() - ) - ) - } - - entry { - SettingsScreenRoot( - modifier = modifier.padding( - start = contentPadding.calculateStartPadding(layoutDirection), - end = contentPadding.calculateEndPadding(layoutDirection), - bottom = contentPadding.calculateBottomPadding() - ) - ) - } - - entry { - StatsScreenRoot( - contentPadding = contentPadding, - modifier = modifier.padding( - start = contentPadding.calculateStartPadding(layoutDirection), - end = contentPadding.calculateEndPadding(layoutDirection), - bottom = contentPadding.calculateBottomPadding() - ) - ) - } } - ) + } } - } else { - Surface( - color = Color.Black, - modifier = Modifier - .fillMaxSize() - .clickable { showAOD = !showAOD }) {} + } + ) { contentPadding -> + SharedTransitionLayout { + NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + transitionSpec = { + ContentTransform( + fadeIn(motionScheme.defaultEffectsSpec()), + fadeOut(motionScheme.defaultEffectsSpec()) + ) + }, + popTransitionSpec = { + ContentTransform( + fadeIn(motionScheme.defaultEffectsSpec()), + fadeOut(motionScheme.defaultEffectsSpec()) + ) + }, + predictivePopTransitionSpec = { + ContentTransform( + fadeIn(motionScheme.defaultEffectsSpec()), + fadeOut(motionScheme.defaultEffectsSpec()) + + scaleOut(targetScale = 0.7f), + ) + }, + entryProvider = entryProvider { + entry { + TimerScreen( + timerState = uiState, + progress = { progress }, + onAction = { action -> + when (action) { + TimerAction.ResetTimer -> + Intent(context, TimerService::class.java).also { + it.action = TimerService.Actions.RESET.toString() + context.startService(it) + } + + is TimerAction.SkipTimer -> + Intent(context, TimerService::class.java).also { + it.action = TimerService.Actions.SKIP.toString() + context.startService(it) + } + + TimerAction.StopAlarm -> + Intent(context, TimerService::class.java).also { + it.action = + TimerService.Actions.STOP_ALARM.toString() + context.startService(it) + } + + TimerAction.ToggleTimer -> + Intent(context, TimerService::class.java).also { + it.action = TimerService.Actions.TOGGLE.toString() + context.startService(it) + } + } + }, + modifier = modifier + .padding( + start = contentPadding.calculateStartPadding(layoutDirection), + end = contentPadding.calculateEndPadding(layoutDirection), + bottom = contentPadding.calculateBottomPadding() + ) + .then( + if (isAODEnabled) Modifier.clickable { + if (backStack.size < 2) backStack.add(Screen.AOD) + } + else Modifier + ), + ) + } + + entry { + AlwaysOnDisplay( + timerState = uiState, + progress = { progress }, + modifier = Modifier + .then( + if (isAODEnabled) Modifier.clickable { + if (backStack.size > 1) backStack.removeLastOrNull() + } + else Modifier + ) + ) + } + + entry { + SettingsScreenRoot( + modifier = modifier.padding( + start = contentPadding.calculateStartPadding(layoutDirection), + end = contentPadding.calculateEndPadding(layoutDirection), + bottom = contentPadding.calculateBottomPadding() + ) + ) + } + + entry { + StatsScreenRoot( + contentPadding = contentPadding, + modifier = modifier.padding( + start = contentPadding.calculateStartPadding(layoutDirection), + end = contentPadding.calculateEndPadding(layoutDirection), + bottom = contentPadding.calculateBottomPadding() + ) + ) + } + } + ) } } } \ No newline at end of file diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/Screen.kt b/app/src/main/java/org/nsh07/pomodoro/ui/Screen.kt index 85482cf..2a43655 100644 --- a/app/src/main/java/org/nsh07/pomodoro/ui/Screen.kt +++ b/app/src/main/java/org/nsh07/pomodoro/ui/Screen.kt @@ -9,6 +9,9 @@ sealed class Screen : NavKey { @Serializable object Timer : Screen() + @Serializable + object AOD : Screen() + @Serializable object Settings : Screen() diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/TimerScreen.kt b/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/TimerScreen.kt index bdb563d..68b9094 100644 --- a/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/TimerScreen.kt +++ b/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/TimerScreen.kt @@ -13,6 +13,8 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.animateColorAsState import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn @@ -81,6 +83,7 @@ import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.navigation3.ui.LocalNavAnimatedContentScope import org.nsh07.pomodoro.R import org.nsh07.pomodoro.ui.theme.AppFonts.openRundeClock import org.nsh07.pomodoro.ui.theme.AppFonts.robotoFlexTopBar @@ -91,7 +94,7 @@ import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerState @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable -fun TimerScreen( +fun SharedTransitionScope.TimerScreen( timerState: TimerState, progress: () -> Float, onAction: (TimerAction) -> Unit, @@ -209,6 +212,12 @@ fun TimerScreen( CircularProgressIndicator( progress = progress, modifier = Modifier + .sharedBounds( + sharedContentState = this@TimerScreen.rememberSharedContentState( + "focus progress" + ), + animatedVisibilityScope = LocalNavAnimatedContentScope.current + ) .widthIn(max = 350.dp) .fillMaxWidth(0.9f) .aspectRatio(1f), @@ -221,6 +230,12 @@ fun TimerScreen( CircularWavyProgressIndicator( progress = progress, modifier = Modifier + .sharedBounds( + sharedContentState = this@TimerScreen.rememberSharedContentState( + "break progress" + ), + animatedVisibilityScope = LocalNavAnimatedContentScope.current + ) .widthIn(max = 350.dp) .fillMaxWidth(0.9f) .aspectRatio(1f), @@ -261,7 +276,11 @@ fun TimerScreen( letterSpacing = (-2).sp ), textAlign = TextAlign.Center, - maxLines = 1 + maxLines = 1, + modifier = Modifier.sharedBounds( + sharedContentState = this@TimerScreen.rememberSharedContentState("clock"), + animatedVisibilityScope = LocalNavAnimatedContentScope.current + ) ) AnimatedVisibility( expanded, @@ -519,11 +538,13 @@ fun TimerScreenPreview() { ) TomatoTheme { Surface { - TimerScreen( - timerState, - { 0.3f }, - {} - ) + SharedTransitionLayout { + TimerScreen( + timerState, + { 0.3f }, + {} + ) + } } } }