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
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<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(
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<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
object Timer : Screen()
@Serializable
object AOD : Screen()
@Serializable
object Settings : Screen()

View File

@@ -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 },
{}
)
}
}
}
}