From 01c75077c7ab575a989adc3142a867ce8235cf03 Mon Sep 17 00:00:00 2001 From: Nishant Mishra Date: Mon, 20 Oct 2025 11:53:44 +0530 Subject: [PATCH 1/7] feat(ui): implement a simple click mechanism to switch to AOD --- .../java/org/nsh07/pomodoro/MainActivity.kt | 2 +- .../java/org/nsh07/pomodoro/ui/AppScreen.kt | 271 ++++++++++-------- 2 files changed, 151 insertions(+), 122 deletions(-) diff --git a/app/src/main/java/org/nsh07/pomodoro/MainActivity.kt b/app/src/main/java/org/nsh07/pomodoro/MainActivity.kt index baf7924..9a40b41 100644 --- a/app/src/main/java/org/nsh07/pomodoro/MainActivity.kt +++ b/app/src/main/java/org/nsh07/pomodoro/MainActivity.kt @@ -51,7 +51,7 @@ class MainActivity : ComponentActivity() { appContainer.appTimerRepository.colorScheme = colorScheme } - AppScreen(timerViewModel = timerViewModel) + AppScreen(timerViewModel = timerViewModel, isAODEnabled = true) } } } 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 59390fd..4bbdc6e 100644 --- a/app/src/main/java/org/nsh07/pomodoro/ui/AppScreen.kt +++ b/app/src/main/java/org/nsh07/pomodoro/ui/AppScreen.kt @@ -8,13 +8,17 @@ package org.nsh07.pomodoro.ui import android.content.Intent +import androidx.compose.animation.AnimatedContent import androidx.compose.animation.ContentTransform import androidx.compose.animation.Crossfade 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 @@ -25,13 +29,17 @@ 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 @@ -55,7 +63,8 @@ import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerViewModel @Composable fun AppScreen( modifier: Modifier = Modifier, - timerViewModel: TimerViewModel = viewModel(factory = TimerViewModel.Factory) + timerViewModel: TimerViewModel = viewModel(factory = TimerViewModel.Factory), + isAODEnabled: Boolean ) { val context = LocalContext.current @@ -69,6 +78,7 @@ fun AppScreen( val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass val backStack = rememberNavBackStack(Screen.Timer) + var showAOD by remember { mutableStateOf(false) } if (uiState.alarmRinging) AlarmDialog { @@ -78,128 +88,147 @@ fun AppScreen( } } - Scaffold( - bottomBar = { - val wide = remember { - windowSizeClass.isWidthAtLeastBreakpoint( - WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND + 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 + ) + ) { contentPadding -> + 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() + ) + ) + } + + 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() + ) + ) + } + } ) } - 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)) } - ) - } - } + } else { + Surface( + color = Color.Black, + modifier = Modifier + .fillMaxSize() + .clickable { showAOD = !showAOD }) {} } - ) { contentPadding -> - 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() - ) - ) - } - - 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 From 33f47dc4c7c0d478eb9cb34621c3f4830d35ce59 Mon Sep 17 00:00:00 2001 From: Nishant Mishra Date: Mon, 20 Oct 2025 13:40:39 +0530 Subject: [PATCH 2/7] feat(ui): implement an AOD screen and transition animations --- .../org/nsh07/pomodoro/ui/AlwaysOnDisplay.kt | 196 ++++++++++++ .../java/org/nsh07/pomodoro/ui/AppScreen.kt | 296 +++++++++--------- .../main/java/org/nsh07/pomodoro/ui/Screen.kt | 3 + .../pomodoro/ui/timerScreen/TimerScreen.kt | 35 ++- 4 files changed, 378 insertions(+), 152 deletions(-) create mode 100644 app/src/main/java/org/nsh07/pomodoro/ui/AlwaysOnDisplay.kt 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 }, + {} + ) + } } } } From aea6a9902e2818bf400e0af29f064623ccfd2b5f Mon Sep 17 00:00:00 2001 From: Nishant Mishra Date: Mon, 20 Oct 2025 13:48:53 +0530 Subject: [PATCH 3/7] fix(ui): Reduce AOD wavy progress wavelength to fix inconsistent-looking waves in AOD vs TimerScreen --- app/src/main/java/org/nsh07/pomodoro/ui/AlwaysOnDisplay.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/AlwaysOnDisplay.kt b/app/src/main/java/org/nsh07/pomodoro/ui/AlwaysOnDisplay.kt index e96d4dc..66d9252 100644 --- a/app/src/main/java/org/nsh07/pomodoro/ui/AlwaysOnDisplay.kt +++ b/app/src/main/java/org/nsh07/pomodoro/ui/AlwaysOnDisplay.kt @@ -155,7 +155,7 @@ fun SharedTransitionScope.AlwaysOnDisplay( }, cap = StrokeCap.Round, ), - wavelength = 94.dp, + wavelength = 42.dp, gapSize = 8.dp ) } From 37f5fb433ef7b3d2e52d536e9d436b5cf06ef5d9 Mon Sep 17 00:00:00 2001 From: Nishant Mishra Date: Mon, 20 Oct 2025 14:02:48 +0530 Subject: [PATCH 4/7] feat(ui): keep screen on in AOD mode --- app/src/main/java/org/nsh07/pomodoro/ui/AlwaysOnDisplay.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/AlwaysOnDisplay.kt b/app/src/main/java/org/nsh07/pomodoro/ui/AlwaysOnDisplay.kt index 66d9252..8009ecb 100644 --- a/app/src/main/java/org/nsh07/pomodoro/ui/AlwaysOnDisplay.kt +++ b/app/src/main/java/org/nsh07/pomodoro/ui/AlwaysOnDisplay.kt @@ -65,6 +65,7 @@ fun SharedTransitionScope.AlwaysOnDisplay( val insetsController = remember { WindowCompat.getInsetsController(window, view) } DisposableEffect(Unit) { + view.keepScreenOn = true insetsController.apply { hide(WindowInsetsCompat.Type.statusBars()) hide(WindowInsetsCompat.Type.navigationBars()) @@ -72,6 +73,7 @@ fun SharedTransitionScope.AlwaysOnDisplay( WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE } onDispose { + view.keepScreenOn = false insetsController.apply { show(WindowInsetsCompat.Type.statusBars()) show(WindowInsetsCompat.Type.navigationBars()) From b16aeb499d8e4f7adb3ab2cb2264f6a766cd0e02 Mon Sep 17 00:00:00 2001 From: Nishant Mishra Date: Tue, 21 Oct 2025 09:26:05 +0530 Subject: [PATCH 5/7] feat(ui): allow phone to be locked while still showing AOD, bump minSDK to 27 --- app/build.gradle.kts | 2 +- .../org/nsh07/pomodoro/ui/AlwaysOnDisplay.kt | 16 ++++++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e9f14c9..107cf83 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -31,7 +31,7 @@ android { defaultConfig { applicationId = "org.nsh07.pomodoro" - minSdk = 26 + minSdk = 27 targetSdk = 36 versionCode = 13 versionName = "1.5.0" diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/AlwaysOnDisplay.kt b/app/src/main/java/org/nsh07/pomodoro/ui/AlwaysOnDisplay.kt index 8009ecb..3234814 100644 --- a/app/src/main/java/org/nsh07/pomodoro/ui/AlwaysOnDisplay.kt +++ b/app/src/main/java/org/nsh07/pomodoro/ui/AlwaysOnDisplay.kt @@ -8,6 +8,8 @@ package org.nsh07.pomodoro.ui import android.app.Activity +import android.view.WindowManager +import androidx.activity.compose.LocalActivity import androidx.compose.animation.SharedTransitionLayout import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.animateColorAsState @@ -61,19 +63,29 @@ fun SharedTransitionScope.AlwaysOnDisplay( var sharedElementTransitionComplete by remember { mutableStateOf(false) } val view = LocalView.current + val activity = LocalActivity.current val window = remember { (view.context as Activity).window } val insetsController = remember { WindowCompat.getInsetsController(window, view) } DisposableEffect(Unit) { - view.keepScreenOn = true + window.addFlags( + WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or + WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON + ) + activity?.setShowWhenLocked(true) insetsController.apply { hide(WindowInsetsCompat.Type.statusBars()) hide(WindowInsetsCompat.Type.navigationBars()) systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE } + onDispose { - view.keepScreenOn = false + window.clearFlags( + WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or + WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON + ) + activity?.setShowWhenLocked(false) insetsController.apply { show(WindowInsetsCompat.Type.statusBars()) show(WindowInsetsCompat.Type.navigationBars()) From 518f172054184c717bc35f3b56a5d8981f3db4cc Mon Sep 17 00:00:00 2001 From: Nishant Mishra Date: Tue, 21 Oct 2025 10:35:21 +0530 Subject: [PATCH 6/7] feat(ui): implement randomizing position in AOD to prevent burn-in --- .../org/nsh07/pomodoro/ui/AlwaysOnDisplay.kt | 176 ++++++++++++------ 1 file changed, 119 insertions(+), 57 deletions(-) diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/AlwaysOnDisplay.kt b/app/src/main/java/org/nsh07/pomodoro/ui/AlwaysOnDisplay.kt index 3234814..ce78c12 100644 --- a/app/src/main/java/org/nsh07/pomodoro/ui/AlwaysOnDisplay.kt +++ b/app/src/main/java/org/nsh07/pomodoro/ui/AlwaysOnDisplay.kt @@ -13,9 +13,11 @@ import androidx.activity.compose.LocalActivity import androidx.compose.animation.SharedTransitionLayout import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateIntAsState import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.size import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularWavyProgressIndicator @@ -27,6 +29,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -37,10 +40,14 @@ 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.platform.LocalWindowInfo 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.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.view.WindowCompat @@ -50,9 +57,20 @@ 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.TimerScreen import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerMode import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerState +import kotlin.random.Random +/** + * Always On Display composable. Must be called within a [SharedTransitionScope] which allows + * animating the clock and progress indicator + * + * @param timerState [TimerState] instance. This must be the same instance as the one used on the + * root [TimerScreen] composable + * @param progress lambda that returns the current progress of the clock + * randomized offset for the clock to allow smooth motion with sharedBounds + */ @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun SharedTransitionScope.AlwaysOnDisplay( @@ -62,8 +80,11 @@ fun SharedTransitionScope.AlwaysOnDisplay( ) { var sharedElementTransitionComplete by remember { mutableStateOf(false) } - val view = LocalView.current val activity = LocalActivity.current + val density = LocalDensity.current + val windowInfo = LocalWindowInfo.current + val view = LocalView.current + val window = remember { (view.context as Activity).window } val insetsController = remember { WindowCompat.getInsetsController(window, view) } @@ -126,70 +147,109 @@ fun SharedTransitionScope.AlwaysOnDisplay( animationSpec = motionScheme.slowEffectsSpec() ) + var randomX by remember { + mutableIntStateOf( + Random.nextInt( + 16.dp.toIntPx(density), + windowInfo.containerSize.width - 266.dp.toIntPx(density) + ) + ) + } + var randomY by remember { + mutableIntStateOf( + Random.nextInt( + 16.dp.toIntPx(density), + windowInfo.containerSize.height - 266.dp.toIntPx(density) + ) + ) + } + + LaunchedEffect(timerState.timeStr[1]) { // Randomize position every minute + if (sharedElementTransitionComplete) { + randomX = Random.nextInt( + 16.dp.toIntPx(density), + windowInfo.containerSize.width - 266.dp.toIntPx(density) + ) + randomY = Random.nextInt( + 16.dp.toIntPx(density), + windowInfo.containerSize.height - 266.dp.toIntPx(density) + ) + } + } + + val x by animateIntAsState(randomX) + val y by animateIntAsState(randomY) + 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, + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.offset { + IntOffset(x, y) + } + ) { + 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 = 42.dp, + gapSize = 8.dp + ) + } + + Text( + text = timerState.timeStr, + style = TextStyle( + fontFamily = openRundeClock, + fontWeight = FontWeight.Bold, + fontSize = 56.sp, + letterSpacing = (-2).sp ), - trackStroke = Stroke( - width = with(LocalDensity.current) { - 12.dp.toPx() - }, - cap = StrokeCap.Round, - ), - wavelength = 42.dp, - gapSize = 8.dp + textAlign = TextAlign.Center, + color = onSurface, + maxLines = 1, + modifier = Modifier.sharedBounds( + sharedContentState = this@AlwaysOnDisplay.rememberSharedContentState("clock"), + animatedVisibilityScope = LocalNavAnimatedContentScope.current + ) ) } - - 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 - ) - ) } } @@ -208,3 +268,5 @@ private fun AlwaysOnDisplayPreview() { } } } + +fun Dp.toIntPx(density: Density) = with(density) { toPx().toInt() } From 4293f0d5f13e030ff97fe226a56ad5909ef40b5a Mon Sep 17 00:00:00 2001 From: Nishant Mishra Date: Tue, 21 Oct 2025 11:41:38 +0530 Subject: [PATCH 7/7] feat(settings): add a settings option to disable AOD disabled by default --- .../java/org/nsh07/pomodoro/MainActivity.kt | 5 +- .../org/nsh07/pomodoro/ui/AlwaysOnDisplay.kt | 4 +- .../ui/settingsScreen/SettingsScreen.kt | 61 +++++++---- .../pomodoro/ui/settingsScreen/ThemeDialog.kt | 22 ++-- .../ui/settingsScreen/ThemePickerListItem.kt | 4 +- .../viewModel/PreferencesState.kt | 3 +- .../viewModel/SettingsViewModel.kt | 14 ++- .../timerScreen/viewModel/TimerViewModel.kt | 3 + app/src/main/res/drawable/aod.xml | 16 +++ app/src/main/res/values/strings.xml | 100 +++++++++--------- 10 files changed, 148 insertions(+), 84 deletions(-) create mode 100644 app/src/main/res/drawable/aod.xml diff --git a/app/src/main/java/org/nsh07/pomodoro/MainActivity.kt b/app/src/main/java/org/nsh07/pomodoro/MainActivity.kt index 9a40b41..a98932a 100644 --- a/app/src/main/java/org/nsh07/pomodoro/MainActivity.kt +++ b/app/src/main/java/org/nsh07/pomodoro/MainActivity.kt @@ -51,7 +51,10 @@ class MainActivity : ComponentActivity() { appContainer.appTimerRepository.colorScheme = colorScheme } - AppScreen(timerViewModel = timerViewModel, isAODEnabled = true) + AppScreen( + timerViewModel = timerViewModel, + isAODEnabled = preferencesState.aodEnabled + ) } } } diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/AlwaysOnDisplay.kt b/app/src/main/java/org/nsh07/pomodoro/ui/AlwaysOnDisplay.kt index ce78c12..8225cc2 100644 --- a/app/src/main/java/org/nsh07/pomodoro/ui/AlwaysOnDisplay.kt +++ b/app/src/main/java/org/nsh07/pomodoro/ui/AlwaysOnDisplay.kt @@ -177,8 +177,8 @@ fun SharedTransitionScope.AlwaysOnDisplay( } } - val x by animateIntAsState(randomX) - val y by animateIntAsState(randomY) + val x by animateIntAsState(randomX, motionScheme.slowSpatialSpec()) + val y by animateIntAsState(randomY, motionScheme.slowSpatialSpec()) Box( modifier = modifier diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/SettingsScreen.kt b/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/SettingsScreen.kt index 67b00a0..b2bca48 100644 --- a/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/SettingsScreen.kt +++ b/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/SettingsScreen.kt @@ -15,6 +15,7 @@ import android.os.Build import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.DrawableRes +import androidx.annotation.StringRes import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -143,6 +144,7 @@ fun SettingsScreenRoot( onAlarmEnabledChange = viewModel::saveAlarmEnabled, onVibrateEnabledChange = viewModel::saveVibrateEnabled, onBlackThemeChange = viewModel::saveBlackTheme, + onAodEnabledChange = viewModel::saveAodEnabled, onAlarmSoundChanged = { viewModel.saveAlarmSound(it) Intent(context, TimerService::class.java).apply { @@ -170,6 +172,7 @@ private fun SettingsScreen( onAlarmEnabledChange: (Boolean) -> Unit, onVibrateEnabledChange: (Boolean) -> Unit, onBlackThemeChange: (Boolean) -> Unit, + onAodEnabledChange: (Boolean) -> Unit, onAlarmSoundChanged: (Uri?) -> Unit, onThemeChange: (String) -> Unit, onColorSchemeChange: (Color) -> Unit, @@ -181,14 +184,14 @@ private fun SettingsScreen( checkedIconColor = colorScheme.primary, ) - val themeMap: Map> = remember { + val themeMap: Map> = remember { mapOf( "auto" to Pair( R.drawable.brightness_auto, - context.getString(R.string.system_default) + R.string.system_default ), - "light" to Pair(R.drawable.light_mode, context.getString(R.string.light)), - "dark" to Pair(R.drawable.dark_mode, context.getString(R.string.dark)) + "light" to Pair(R.drawable.light_mode, R.string.light), + "dark" to Pair(R.drawable.dark_mode, R.string.dark) ) } val reverseThemeMap: Map = remember { @@ -232,27 +235,39 @@ private fun SettingsScreen( putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, alarmSound.toUri()) } - val switchItems = remember(preferencesState.blackTheme, alarmEnabled, vibrateEnabled) { + val switchItems = remember( + preferencesState.blackTheme, + preferencesState.aodEnabled, + alarmEnabled, + vibrateEnabled + ) { listOf( SettingsSwitchItem( checked = preferencesState.blackTheme, icon = R.drawable.contrast, - label = context.getString(R.string.black_theme), - description = context.getString(R.string.black_theme_desc), + label = R.string.black_theme, + description = R.string.black_theme_desc, onClick = onBlackThemeChange ), + SettingsSwitchItem( + checked = preferencesState.aodEnabled, + icon = R.drawable.aod, + label = R.string.always_on_display, + description = R.string.always_on_display_desc, + onClick = onAodEnabledChange + ), SettingsSwitchItem( checked = alarmEnabled, icon = R.drawable.alarm_on, - label = context.getString(R.string.alarm), - description = context.getString(R.string.alarm_desc), + label = R.string.alarm, + description = R.string.alarm_desc, onClick = onAlarmEnabledChange ), SettingsSwitchItem( checked = vibrateEnabled, icon = R.drawable.mobile_vibrate, - label = context.getString(R.string.vibrate), - description = context.getString(R.string.vibrate_desc), + label = R.string.vibrate, + description = R.string.vibrate_desc, onClick = onVibrateEnabledChange ) ) @@ -404,14 +419,13 @@ private fun SettingsScreen( .clip(middleListItemShape) ) } - item { - val item = switchItems[0] + itemsIndexed(switchItems.take(2)) { index, item -> ListItem( leadingContent = { Icon(painterResource(item.icon), contentDescription = null) }, - headlineContent = { Text(item.label) }, - supportingContent = { Text(item.description) }, + headlineContent = { Text(stringResource(item.label)) }, + supportingContent = { Text(stringResource(item.description)) }, trailingContent = { Switch( checked = item.checked, @@ -435,7 +449,9 @@ private fun SettingsScreen( ) }, colors = listItemColors, - modifier = Modifier.clip(bottomListItemShape) + modifier = Modifier + .padding(top = if (index != 0) 16.dp else 0.dp) + .clip(if (index == 0) bottomListItemShape else cardShape) ) } @@ -454,13 +470,13 @@ private fun SettingsScreen( .clickable(onClick = { ringtonePickerLauncher.launch(intent) }) ) } - itemsIndexed(switchItems.drop(1)) { index, item -> + itemsIndexed(switchItems.drop(2)) { index, item -> ListItem( leadingContent = { Icon(painterResource(item.icon), contentDescription = null) }, - headlineContent = { Text(item.label) }, - supportingContent = { Text(item.description) }, + headlineContent = { Text(stringResource(item.label)) }, + supportingContent = { Text(stringResource(item.description)) }, trailingContent = { Switch( checked = item.checked, @@ -487,7 +503,7 @@ private fun SettingsScreen( modifier = Modifier .clip( when (index) { - switchItems.lastIndex - 1 -> bottomListItemShape + switchItems.lastIndex - 2 -> bottomListItemShape else -> middleListItemShape } ) @@ -546,6 +562,7 @@ fun SettingsScreenPreview() { onAlarmEnabledChange = {}, onVibrateEnabledChange = {}, onBlackThemeChange = {}, + onAodEnabledChange = {}, onAlarmSoundChanged = {}, onThemeChange = {}, onColorSchemeChange = {}, @@ -557,7 +574,7 @@ fun SettingsScreenPreview() { data class SettingsSwitchItem( val checked: Boolean, @param:DrawableRes val icon: Int, - val label: String, - val description: String, + @param:StringRes val label: Int, + @param:StringRes val description: Int, val onClick: (Boolean) -> Unit ) diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/ThemeDialog.kt b/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/ThemeDialog.kt index 67f4d7a..1de30cd 100644 --- a/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/ThemeDialog.kt +++ b/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/ThemeDialog.kt @@ -31,11 +31,12 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role @@ -50,14 +51,16 @@ import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.topListItemShape @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun ThemeDialog( - themeMap: Map>, + themeMap: Map>, reverseThemeMap: Map, theme: String, setShowThemeDialog: (Boolean) -> Unit, onThemeChange: (String) -> Unit ) { val selectedOption = - remember { mutableStateOf(themeMap[theme]!!.second) } + remember { mutableIntStateOf(themeMap[theme]!!.second) } + + val context = LocalContext.current BasicAlertDialog( onDismissRequest = { setShowThemeDialog(false) } @@ -80,7 +83,7 @@ fun ThemeDialog( verticalArrangement = Arrangement.spacedBy(2.dp), modifier = Modifier.selectableGroup() ) { - themeMap.entries.forEachIndexed { index: Int, pair: Map.Entry> -> + themeMap.entries.forEachIndexed { index: Int, pair: Map.Entry> -> val text = pair.value.second val selected = text == selectedOption.value @@ -94,7 +97,10 @@ fun ThemeDialog( } }, headlineContent = { - Text(text = text, style = MaterialTheme.typography.bodyLarge) + Text( + text = stringResource(text), + style = MaterialTheme.typography.bodyLarge + ) }, colors = if (!selected) listItemColors else selectedListItemColors, modifier = Modifier @@ -110,7 +116,11 @@ fun ThemeDialog( selected = (text == selectedOption.value), onClick = { selectedOption.value = text - onThemeChange(reverseThemeMap[selectedOption.value]!!) + onThemeChange( + reverseThemeMap[context.getString( + selectedOption.intValue + )]!! + ) }, role = Role.RadioButton ) diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/ThemePickerListItem.kt b/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/ThemePickerListItem.kt index 2bee9c5..0c66f00 100644 --- a/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/ThemePickerListItem.kt +++ b/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/ThemePickerListItem.kt @@ -25,7 +25,7 @@ import org.nsh07.pomodoro.ui.theme.CustomColors.listItemColors @Composable fun ThemePickerListItem( theme: String, - themeMap: Map>, + themeMap: Map>, reverseThemeMap: Map, items: Int, index: Int, @@ -53,7 +53,7 @@ fun ThemePickerListItem( }, headlineContent = { Text(stringResource(R.string.theme)) }, supportingContent = { - Text(themeMap[theme]!!.second) + Text(stringResource(themeMap[theme]!!.second)) }, colors = listItemColors, items = items, diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/viewModel/PreferencesState.kt b/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/viewModel/PreferencesState.kt index 0ae4849..7231852 100644 --- a/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/viewModel/PreferencesState.kt +++ b/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/viewModel/PreferencesState.kt @@ -14,5 +14,6 @@ import androidx.compose.ui.graphics.Color data class PreferencesState( val theme: String = "auto", val colorScheme: String = Color.White.toString(), - val blackTheme: Boolean = false + val blackTheme: Boolean = false, + val aodEnabled: Boolean = false ) diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsViewModel.kt b/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsViewModel.kt index 5f604c1..454bf82 100644 --- a/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsViewModel.kt +++ b/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/viewModel/SettingsViewModel.kt @@ -80,12 +80,15 @@ class SettingsViewModel( ?: preferenceRepository.saveStringPreference("color_scheme", Color.White.toString()) val blackTheme = preferenceRepository.getBooleanPreference("black_theme") ?: preferenceRepository.saveBooleanPreference("black_theme", false) + val aodEnabled = preferenceRepository.getBooleanPreference("aod_enabled") + ?: preferenceRepository.saveBooleanPreference("aod_enabled", false) _preferencesState.update { currentState -> currentState.copy( theme = theme, colorScheme = colorScheme, - blackTheme = blackTheme + blackTheme = blackTheme, + aodEnabled = aodEnabled ) } } @@ -196,6 +199,15 @@ class SettingsViewModel( } } + fun saveAodEnabled(aodEnabled: Boolean) { + viewModelScope.launch { + _preferencesState.update { currentState -> + currentState.copy(aodEnabled = aodEnabled) + } + preferenceRepository.saveBooleanPreference("aod_enabled", aodEnabled) + } + } + companion object { val Factory: ViewModelProvider.Factory = viewModelFactory { initializer { diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerViewModel.kt b/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerViewModel.kt index 2202d42..53c23b5 100644 --- a/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerViewModel.kt +++ b/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerViewModel.kt @@ -95,6 +95,9 @@ class TimerViewModel( ) ).toUri() + preferenceRepository.getBooleanPreference("aod_enabled") + ?: preferenceRepository.saveBooleanPreference("aod_enabled", false) + _time.update { timerRepository.focusTime } cycles = 0 startTime = 0L diff --git a/app/src/main/res/drawable/aod.xml b/app/src/main/res/drawable/aod.xml new file mode 100644 index 0000000..ab821c8 --- /dev/null +++ b/app/src/main/res/drawable/aod.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 067cff5..c185a08 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,60 +1,62 @@ - Tomato - Start - Stop - Focus - Short break - Long break - Exit - Skip - Stop alarm - %1$s min remaining - Paused - Completed - Up next: %1$s (%2$s) - Start next - Choose color scheme - OK - Color scheme - Dynamic - Color - System default Alarm - Light - Dark - Choose theme - Productivity analysis - Focus durations at different times of the day + Ring alarm when a timer completes Alarm sound + Always On Display + Tap anywhere when viewing the timer to switch to AOD mode + Tomato Black theme Use a pure black dark theme - Ring alarm when a timer completes - Vibrate - Vibrate when a timer completes - Theme - Settings + Break + Choose color scheme + Choose theme + Color + Color scheme + Completed + Dark + Dynamic + Exit + Focus + focus per day (avg) + Last month + Last week + Last year + Light + Long break + %1$s min remaining + Monthly productivity analysis + More + More info + OK + Pause + Paused + Play + A \"session\" is a sequence of pomodoro intervals that contain focus intervals, short break intervals, and a long break interval. The last break of a session is always a long break. + Productivity analysis + Focus durations at different times of the day + Restart Session length Focus intervals in one session: %1$d - A \"session\" is a sequence of pomodoro intervals that contain focus intervals, short break intervals, and a long break interval. The last break of a session is always a long break. - Stats - Today - Break - Last week - focus per day (avg) - More info - Weekly productivity analysis - Last month - Monthly productivity analysis - Stop Alarm? - Current timer session is complete. Tap anywhere to stop the alarm. - %1$d of %2$d - More - Pause - Play - Restart + Settings + Short break + Skip Skip to next - Up next + Start + Start next + Stats + Stop + Stop alarm + Current timer session is complete. Tap anywhere to stop the alarm. + Stop Alarm? + System default + Theme Timer Timer progress - Last year + %1$d of %2$d + Today + Up next + Up next: %1$s (%2$s) + Vibrate + Vibrate when a timer completes + Weekly productivity analysis \ No newline at end of file