feat(ui): implement an AOD screen and transition animations

This commit is contained in:
Nishant Mishra
2025-10-20 13:40:39 +05:30
parent 01c75077c7
commit 33f47dc4c7
4 changed files with 378 additions and 152 deletions

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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
)
}
}
}

View File

@@ -8,17 +8,16 @@
package org.nsh07.pomodoro.ui package org.nsh07.pomodoro.ui
import android.content.Intent import android.content.Intent
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ContentTransform import androidx.compose.animation.ContentTransform
import androidx.compose.animation.Crossfade import androidx.compose.animation.Crossfade
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleOut import androidx.compose.animation.scaleOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
@@ -29,17 +28,13 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.ShortNavigationBar import androidx.compose.material3.ShortNavigationBar
import androidx.compose.material3.ShortNavigationBarArrangement import androidx.compose.material3.ShortNavigationBarArrangement
import androidx.compose.material3.ShortNavigationBarItem import androidx.compose.material3.ShortNavigationBarItem
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
@@ -78,7 +73,6 @@ fun AppScreen(
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
val backStack = rememberNavBackStack(Screen.Timer) val backStack = rememberNavBackStack(Screen.Timer)
var showAOD by remember { mutableStateOf(false) }
if (uiState.alarmRinging) if (uiState.alarmRinging)
AlarmDialog { AlarmDialog {
@@ -88,147 +82,159 @@ fun AppScreen(
} }
} }
AnimatedContent(
showAOD, Scaffold(
transitionSpec = { fadeIn().togetherWith(fadeOut()) } bottomBar = {
) { aod -> AnimatedVisibility(
if (!aod) { backStack.last() !is Screen.AOD,
Scaffold( enter = fadeIn(),
bottomBar = { exit = fadeOut()
val wide = remember { ) {
windowSizeClass.isWidthAtLeastBreakpoint( val wide = remember {
WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND 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
) )
) { contentPadding -> }
NavDisplay( ShortNavigationBar(
backStack = backStack, arrangement =
onBack = { backStack.removeLastOrNull() }, if (wide) ShortNavigationBarArrangement.Centered
transitionSpec = { else ShortNavigationBarArrangement.EqualWeight
ContentTransform( ) {
fadeIn(motionScheme.defaultEffectsSpec()), screens.forEach {
fadeOut(motionScheme.defaultEffectsSpec()) 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<Screen.Timer> {
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<Screen.Settings> {
SettingsScreenRoot(
modifier = modifier.padding(
start = contentPadding.calculateStartPadding(layoutDirection),
end = contentPadding.calculateEndPadding(layoutDirection),
bottom = contentPadding.calculateBottomPadding()
)
)
}
entry<Screen.Stats> {
StatsScreenRoot(
contentPadding = contentPadding,
modifier = modifier.padding(
start = contentPadding.calculateStartPadding(layoutDirection),
end = contentPadding.calculateEndPadding(layoutDirection),
bottom = contentPadding.calculateBottomPadding()
)
)
}
} }
) }
} }
} else { }
Surface( ) { contentPadding ->
color = Color.Black, SharedTransitionLayout {
modifier = Modifier NavDisplay(
.fillMaxSize() backStack = backStack,
.clickable { showAOD = !showAOD }) {} 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<Screen.Timer> {
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<Screen.AOD> {
AlwaysOnDisplay(
timerState = uiState,
progress = { progress },
modifier = Modifier
.then(
if (isAODEnabled) Modifier.clickable {
if (backStack.size > 1) backStack.removeLastOrNull()
}
else Modifier
)
)
}
entry<Screen.Settings> {
SettingsScreenRoot(
modifier = modifier.padding(
start = contentPadding.calculateStartPadding(layoutDirection),
end = contentPadding.calculateEndPadding(layoutDirection),
bottom = contentPadding.calculateBottomPadding()
)
)
}
entry<Screen.Stats> {
StatsScreenRoot(
contentPadding = contentPadding,
modifier = modifier.padding(
start = contentPadding.calculateStartPadding(layoutDirection),
end = contentPadding.calculateEndPadding(layoutDirection),
bottom = contentPadding.calculateBottomPadding()
)
)
}
}
)
} }
} }
} }

View File

@@ -9,6 +9,9 @@ sealed class Screen : NavKey {
@Serializable @Serializable
object Timer : Screen() object Timer : Screen()
@Serializable
object AOD : Screen()
@Serializable @Serializable
object Settings : Screen() object Settings : Screen()

View File

@@ -13,6 +13,8 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.expandVertically import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn 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.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.navigation3.ui.LocalNavAnimatedContentScope
import org.nsh07.pomodoro.R import org.nsh07.pomodoro.R
import org.nsh07.pomodoro.ui.theme.AppFonts.openRundeClock import org.nsh07.pomodoro.ui.theme.AppFonts.openRundeClock
import org.nsh07.pomodoro.ui.theme.AppFonts.robotoFlexTopBar 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) @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun TimerScreen( fun SharedTransitionScope.TimerScreen(
timerState: TimerState, timerState: TimerState,
progress: () -> Float, progress: () -> Float,
onAction: (TimerAction) -> Unit, onAction: (TimerAction) -> Unit,
@@ -209,6 +212,12 @@ fun TimerScreen(
CircularProgressIndicator( CircularProgressIndicator(
progress = progress, progress = progress,
modifier = Modifier modifier = Modifier
.sharedBounds(
sharedContentState = this@TimerScreen.rememberSharedContentState(
"focus progress"
),
animatedVisibilityScope = LocalNavAnimatedContentScope.current
)
.widthIn(max = 350.dp) .widthIn(max = 350.dp)
.fillMaxWidth(0.9f) .fillMaxWidth(0.9f)
.aspectRatio(1f), .aspectRatio(1f),
@@ -221,6 +230,12 @@ fun TimerScreen(
CircularWavyProgressIndicator( CircularWavyProgressIndicator(
progress = progress, progress = progress,
modifier = Modifier modifier = Modifier
.sharedBounds(
sharedContentState = this@TimerScreen.rememberSharedContentState(
"break progress"
),
animatedVisibilityScope = LocalNavAnimatedContentScope.current
)
.widthIn(max = 350.dp) .widthIn(max = 350.dp)
.fillMaxWidth(0.9f) .fillMaxWidth(0.9f)
.aspectRatio(1f), .aspectRatio(1f),
@@ -261,7 +276,11 @@ fun TimerScreen(
letterSpacing = (-2).sp letterSpacing = (-2).sp
), ),
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
maxLines = 1 maxLines = 1,
modifier = Modifier.sharedBounds(
sharedContentState = this@TimerScreen.rememberSharedContentState("clock"),
animatedVisibilityScope = LocalNavAnimatedContentScope.current
)
) )
AnimatedVisibility( AnimatedVisibility(
expanded, expanded,
@@ -519,11 +538,13 @@ fun TimerScreenPreview() {
) )
TomatoTheme { TomatoTheme {
Surface { Surface {
TimerScreen( SharedTransitionLayout {
timerState, TimerScreen(
{ 0.3f }, timerState,
{} { 0.3f },
) {}
)
}
} }
} }
} }