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