Merge pull request #82 from nsh07/always-on-display

feat(ui): Always On Display (AOD) mode
This commit is contained in:
Nishant Mishra
2025-10-21 11:44:53 +05:30
committed by GitHub
14 changed files with 597 additions and 202 deletions

View File

@@ -31,7 +31,7 @@ android {
defaultConfig { defaultConfig {
applicationId = "org.nsh07.pomodoro" applicationId = "org.nsh07.pomodoro"
minSdk = 26 minSdk = 27
targetSdk = 36 targetSdk = 36
versionCode = 13 versionCode = 13
versionName = "1.5.0" versionName = "1.5.0"

View File

@@ -51,7 +51,10 @@ class MainActivity : ComponentActivity() {
appContainer.appTimerRepository.colorScheme = colorScheme appContainer.appTimerRepository.colorScheme = colorScheme
} }
AppScreen(timerViewModel = timerViewModel) AppScreen(
timerViewModel = timerViewModel,
isAODEnabled = preferencesState.aodEnabled
)
} }
} }
} }

View File

@@ -0,0 +1,272 @@
/*
* 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 android.view.WindowManager
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
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.mutableIntStateOf
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.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
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.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(
timerState: TimerState,
progress: () -> Float,
modifier: Modifier = Modifier
) {
var sharedElementTransitionComplete by remember { mutableStateOf(false) }
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) }
DisposableEffect(Unit) {
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 {
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())
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()
)
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, motionScheme.slowSpatialSpec())
val y by animateIntAsState(randomY, motionScheme.slowSpatialSpec())
Box(
modifier = modifier
.fillMaxSize()
.background(surface)
) {
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
),
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
)
}
}
}
fun Dp.toIntPx(density: Density) = with(density) { toPx().toInt() }

View File

@@ -8,11 +8,14 @@
package org.nsh07.pomodoro.ui package org.nsh07.pomodoro.ui
import android.content.Intent import android.content.Intent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ContentTransform import androidx.compose.animation.ContentTransform
import androidx.compose.animation.Crossfade import androidx.compose.animation.Crossfade
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleOut import androidx.compose.animation.scaleOut
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -55,7 +58,8 @@ import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerViewModel
@Composable @Composable
fun AppScreen( fun AppScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
timerViewModel: TimerViewModel = viewModel(factory = TimerViewModel.Factory) timerViewModel: TimerViewModel = viewModel(factory = TimerViewModel.Factory),
isAODEnabled: Boolean
) { ) {
val context = LocalContext.current val context = LocalContext.current
@@ -78,128 +82,159 @@ fun AppScreen(
} }
} }
Scaffold( Scaffold(
bottomBar = { bottomBar = {
val wide = remember { AnimatedVisibility(
windowSizeClass.isWidthAtLeastBreakpoint( backStack.last() !is Screen.AOD,
WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND enter = fadeIn(),
) exit = fadeOut()
}
ShortNavigationBar(
arrangement =
if (wide) ShortNavigationBarArrangement.Centered
else ShortNavigationBarArrangement.EqualWeight
) { ) {
screens.forEach { val wide = remember {
val selected = backStack.last() == it.route windowSizeClass.isWidthAtLeastBreakpoint(
ShortNavigationBarItem( WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND
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)) }
) )
} }
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)) }
)
}
}
} }
} }
) { contentPadding -> ) { contentPadding ->
NavDisplay( SharedTransitionLayout {
backStack = backStack, NavDisplay(
onBack = { backStack.removeLastOrNull() }, backStack = backStack,
transitionSpec = { onBack = { backStack.removeLastOrNull() },
ContentTransform( transitionSpec = {
fadeIn(motionScheme.defaultEffectsSpec()), ContentTransform(
fadeOut(motionScheme.defaultEffectsSpec()) 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()
)
) )
} },
popTransitionSpec = {
entry<Screen.Settings> { ContentTransform(
SettingsScreenRoot( fadeIn(motionScheme.defaultEffectsSpec()),
modifier = modifier.padding( fadeOut(motionScheme.defaultEffectsSpec())
start = contentPadding.calculateStartPadding(layoutDirection),
end = contentPadding.calculateEndPadding(layoutDirection),
bottom = contentPadding.calculateBottomPadding()
)
) )
} },
predictivePopTransitionSpec = {
entry<Screen.Stats> { ContentTransform(
StatsScreenRoot( fadeIn(motionScheme.defaultEffectsSpec()),
contentPadding = contentPadding, fadeOut(motionScheme.defaultEffectsSpec()) +
modifier = modifier.padding( scaleOut(targetScale = 0.7f),
start = contentPadding.calculateStartPadding(layoutDirection),
end = contentPadding.calculateEndPadding(layoutDirection),
bottom = contentPadding.calculateBottomPadding()
)
) )
},
entryProvider = entryProvider {
entry<Screen.Timer> {
TimerScreen(
timerState = uiState,
progress = { progress },
onAction = { action ->
when (action) {
TimerAction.ResetTimer ->
Intent(context, TimerService::class.java).also {
it.action = TimerService.Actions.RESET.toString()
context.startService(it)
}
is TimerAction.SkipTimer ->
Intent(context, TimerService::class.java).also {
it.action = TimerService.Actions.SKIP.toString()
context.startService(it)
}
TimerAction.StopAlarm ->
Intent(context, TimerService::class.java).also {
it.action =
TimerService.Actions.STOP_ALARM.toString()
context.startService(it)
}
TimerAction.ToggleTimer ->
Intent(context, TimerService::class.java).also {
it.action = TimerService.Actions.TOGGLE.toString()
context.startService(it)
}
}
},
modifier = modifier
.padding(
start = contentPadding.calculateStartPadding(layoutDirection),
end = contentPadding.calculateEndPadding(layoutDirection),
bottom = contentPadding.calculateBottomPadding()
)
.then(
if (isAODEnabled) Modifier.clickable {
if (backStack.size < 2) backStack.add(Screen.AOD)
}
else Modifier
),
)
}
entry<Screen.AOD> {
AlwaysOnDisplay(
timerState = uiState,
progress = { progress },
modifier = Modifier
.then(
if (isAODEnabled) Modifier.clickable {
if (backStack.size > 1) backStack.removeLastOrNull()
}
else Modifier
)
)
}
entry<Screen.Settings> {
SettingsScreenRoot(
modifier = modifier.padding(
start = contentPadding.calculateStartPadding(layoutDirection),
end = contentPadding.calculateEndPadding(layoutDirection),
bottom = contentPadding.calculateBottomPadding()
)
)
}
entry<Screen.Stats> {
StatsScreenRoot(
contentPadding = contentPadding,
modifier = modifier.padding(
start = contentPadding.calculateStartPadding(layoutDirection),
end = contentPadding.calculateEndPadding(layoutDirection),
bottom = contentPadding.calculateBottomPadding()
)
)
}
} }
} )
) }
} }
} }

View File

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

View File

@@ -15,6 +15,7 @@ import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -143,6 +144,7 @@ fun SettingsScreenRoot(
onAlarmEnabledChange = viewModel::saveAlarmEnabled, onAlarmEnabledChange = viewModel::saveAlarmEnabled,
onVibrateEnabledChange = viewModel::saveVibrateEnabled, onVibrateEnabledChange = viewModel::saveVibrateEnabled,
onBlackThemeChange = viewModel::saveBlackTheme, onBlackThemeChange = viewModel::saveBlackTheme,
onAodEnabledChange = viewModel::saveAodEnabled,
onAlarmSoundChanged = { onAlarmSoundChanged = {
viewModel.saveAlarmSound(it) viewModel.saveAlarmSound(it)
Intent(context, TimerService::class.java).apply { Intent(context, TimerService::class.java).apply {
@@ -170,6 +172,7 @@ private fun SettingsScreen(
onAlarmEnabledChange: (Boolean) -> Unit, onAlarmEnabledChange: (Boolean) -> Unit,
onVibrateEnabledChange: (Boolean) -> Unit, onVibrateEnabledChange: (Boolean) -> Unit,
onBlackThemeChange: (Boolean) -> Unit, onBlackThemeChange: (Boolean) -> Unit,
onAodEnabledChange: (Boolean) -> Unit,
onAlarmSoundChanged: (Uri?) -> Unit, onAlarmSoundChanged: (Uri?) -> Unit,
onThemeChange: (String) -> Unit, onThemeChange: (String) -> Unit,
onColorSchemeChange: (Color) -> Unit, onColorSchemeChange: (Color) -> Unit,
@@ -181,14 +184,14 @@ private fun SettingsScreen(
checkedIconColor = colorScheme.primary, checkedIconColor = colorScheme.primary,
) )
val themeMap: Map<String, Pair<Int, String>> = remember { val themeMap: Map<String, Pair<Int, Int>> = remember {
mapOf( mapOf(
"auto" to Pair( "auto" to Pair(
R.drawable.brightness_auto, 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)), "light" to Pair(R.drawable.light_mode, R.string.light),
"dark" to Pair(R.drawable.dark_mode, context.getString(R.string.dark)) "dark" to Pair(R.drawable.dark_mode, R.string.dark)
) )
} }
val reverseThemeMap: Map<String, String> = remember { val reverseThemeMap: Map<String, String> = remember {
@@ -232,27 +235,39 @@ private fun SettingsScreen(
putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, alarmSound.toUri()) 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( listOf(
SettingsSwitchItem( SettingsSwitchItem(
checked = preferencesState.blackTheme, checked = preferencesState.blackTheme,
icon = R.drawable.contrast, icon = R.drawable.contrast,
label = context.getString(R.string.black_theme), label = R.string.black_theme,
description = context.getString(R.string.black_theme_desc), description = R.string.black_theme_desc,
onClick = onBlackThemeChange 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( SettingsSwitchItem(
checked = alarmEnabled, checked = alarmEnabled,
icon = R.drawable.alarm_on, icon = R.drawable.alarm_on,
label = context.getString(R.string.alarm), label = R.string.alarm,
description = context.getString(R.string.alarm_desc), description = R.string.alarm_desc,
onClick = onAlarmEnabledChange onClick = onAlarmEnabledChange
), ),
SettingsSwitchItem( SettingsSwitchItem(
checked = vibrateEnabled, checked = vibrateEnabled,
icon = R.drawable.mobile_vibrate, icon = R.drawable.mobile_vibrate,
label = context.getString(R.string.vibrate), label = R.string.vibrate,
description = context.getString(R.string.vibrate_desc), description = R.string.vibrate_desc,
onClick = onVibrateEnabledChange onClick = onVibrateEnabledChange
) )
) )
@@ -404,14 +419,13 @@ private fun SettingsScreen(
.clip(middleListItemShape) .clip(middleListItemShape)
) )
} }
item { itemsIndexed(switchItems.take(2)) { index, item ->
val item = switchItems[0]
ListItem( ListItem(
leadingContent = { leadingContent = {
Icon(painterResource(item.icon), contentDescription = null) Icon(painterResource(item.icon), contentDescription = null)
}, },
headlineContent = { Text(item.label) }, headlineContent = { Text(stringResource(item.label)) },
supportingContent = { Text(item.description) }, supportingContent = { Text(stringResource(item.description)) },
trailingContent = { trailingContent = {
Switch( Switch(
checked = item.checked, checked = item.checked,
@@ -435,7 +449,9 @@ private fun SettingsScreen(
) )
}, },
colors = listItemColors, 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) }) .clickable(onClick = { ringtonePickerLauncher.launch(intent) })
) )
} }
itemsIndexed(switchItems.drop(1)) { index, item -> itemsIndexed(switchItems.drop(2)) { index, item ->
ListItem( ListItem(
leadingContent = { leadingContent = {
Icon(painterResource(item.icon), contentDescription = null) Icon(painterResource(item.icon), contentDescription = null)
}, },
headlineContent = { Text(item.label) }, headlineContent = { Text(stringResource(item.label)) },
supportingContent = { Text(item.description) }, supportingContent = { Text(stringResource(item.description)) },
trailingContent = { trailingContent = {
Switch( Switch(
checked = item.checked, checked = item.checked,
@@ -487,7 +503,7 @@ private fun SettingsScreen(
modifier = Modifier modifier = Modifier
.clip( .clip(
when (index) { when (index) {
switchItems.lastIndex - 1 -> bottomListItemShape switchItems.lastIndex - 2 -> bottomListItemShape
else -> middleListItemShape else -> middleListItemShape
} }
) )
@@ -546,6 +562,7 @@ fun SettingsScreenPreview() {
onAlarmEnabledChange = {}, onAlarmEnabledChange = {},
onVibrateEnabledChange = {}, onVibrateEnabledChange = {},
onBlackThemeChange = {}, onBlackThemeChange = {},
onAodEnabledChange = {},
onAlarmSoundChanged = {}, onAlarmSoundChanged = {},
onThemeChange = {}, onThemeChange = {},
onColorSchemeChange = {}, onColorSchemeChange = {},
@@ -557,7 +574,7 @@ fun SettingsScreenPreview() {
data class SettingsSwitchItem( data class SettingsSwitchItem(
val checked: Boolean, val checked: Boolean,
@param:DrawableRes val icon: Int, @param:DrawableRes val icon: Int,
val label: String, @param:StringRes val label: Int,
val description: String, @param:StringRes val description: Int,
val onClick: (Boolean) -> Unit val onClick: (Boolean) -> Unit
) )

View File

@@ -31,11 +31,12 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
@@ -50,14 +51,16 @@ import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.topListItemShape
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun ThemeDialog( fun ThemeDialog(
themeMap: Map<String, Pair<Int, String>>, themeMap: Map<String, Pair<Int, Int>>,
reverseThemeMap: Map<String, String>, reverseThemeMap: Map<String, String>,
theme: String, theme: String,
setShowThemeDialog: (Boolean) -> Unit, setShowThemeDialog: (Boolean) -> Unit,
onThemeChange: (String) -> Unit onThemeChange: (String) -> Unit
) { ) {
val selectedOption = val selectedOption =
remember { mutableStateOf(themeMap[theme]!!.second) } remember { mutableIntStateOf(themeMap[theme]!!.second) }
val context = LocalContext.current
BasicAlertDialog( BasicAlertDialog(
onDismissRequest = { setShowThemeDialog(false) } onDismissRequest = { setShowThemeDialog(false) }
@@ -80,7 +83,7 @@ fun ThemeDialog(
verticalArrangement = Arrangement.spacedBy(2.dp), verticalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier.selectableGroup() modifier = Modifier.selectableGroup()
) { ) {
themeMap.entries.forEachIndexed { index: Int, pair: Map.Entry<String, Pair<Int, String>> -> themeMap.entries.forEachIndexed { index: Int, pair: Map.Entry<String, Pair<Int, Int>> ->
val text = pair.value.second val text = pair.value.second
val selected = text == selectedOption.value val selected = text == selectedOption.value
@@ -94,7 +97,10 @@ fun ThemeDialog(
} }
}, },
headlineContent = { headlineContent = {
Text(text = text, style = MaterialTheme.typography.bodyLarge) Text(
text = stringResource(text),
style = MaterialTheme.typography.bodyLarge
)
}, },
colors = if (!selected) listItemColors else selectedListItemColors, colors = if (!selected) listItemColors else selectedListItemColors,
modifier = Modifier modifier = Modifier
@@ -110,7 +116,11 @@ fun ThemeDialog(
selected = (text == selectedOption.value), selected = (text == selectedOption.value),
onClick = { onClick = {
selectedOption.value = text selectedOption.value = text
onThemeChange(reverseThemeMap[selectedOption.value]!!) onThemeChange(
reverseThemeMap[context.getString(
selectedOption.intValue
)]!!
)
}, },
role = Role.RadioButton role = Role.RadioButton
) )

View File

@@ -25,7 +25,7 @@ import org.nsh07.pomodoro.ui.theme.CustomColors.listItemColors
@Composable @Composable
fun ThemePickerListItem( fun ThemePickerListItem(
theme: String, theme: String,
themeMap: Map<String, Pair<Int, String>>, themeMap: Map<String, Pair<Int, Int>>,
reverseThemeMap: Map<String, String>, reverseThemeMap: Map<String, String>,
items: Int, items: Int,
index: Int, index: Int,
@@ -53,7 +53,7 @@ fun ThemePickerListItem(
}, },
headlineContent = { Text(stringResource(R.string.theme)) }, headlineContent = { Text(stringResource(R.string.theme)) },
supportingContent = { supportingContent = {
Text(themeMap[theme]!!.second) Text(stringResource(themeMap[theme]!!.second))
}, },
colors = listItemColors, colors = listItemColors,
items = items, items = items,

View File

@@ -14,5 +14,6 @@ import androidx.compose.ui.graphics.Color
data class PreferencesState( data class PreferencesState(
val theme: String = "auto", val theme: String = "auto",
val colorScheme: String = Color.White.toString(), val colorScheme: String = Color.White.toString(),
val blackTheme: Boolean = false val blackTheme: Boolean = false,
val aodEnabled: Boolean = false
) )

View File

@@ -80,12 +80,15 @@ class SettingsViewModel(
?: preferenceRepository.saveStringPreference("color_scheme", Color.White.toString()) ?: preferenceRepository.saveStringPreference("color_scheme", Color.White.toString())
val blackTheme = preferenceRepository.getBooleanPreference("black_theme") val blackTheme = preferenceRepository.getBooleanPreference("black_theme")
?: preferenceRepository.saveBooleanPreference("black_theme", false) ?: preferenceRepository.saveBooleanPreference("black_theme", false)
val aodEnabled = preferenceRepository.getBooleanPreference("aod_enabled")
?: preferenceRepository.saveBooleanPreference("aod_enabled", false)
_preferencesState.update { currentState -> _preferencesState.update { currentState ->
currentState.copy( currentState.copy(
theme = theme, theme = theme,
colorScheme = colorScheme, 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 { companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory { val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer { initializer {

View File

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

View File

@@ -95,6 +95,9 @@ class TimerViewModel(
) )
).toUri() ).toUri()
preferenceRepository.getBooleanPreference("aod_enabled")
?: preferenceRepository.saveBooleanPreference("aod_enabled", false)
_time.update { timerRepository.focusTime } _time.update { timerRepository.focusTime }
cycles = 0 cycles = 0
startTime = 0L startTime = 0L

View File

@@ -0,0 +1,16 @@
<!--
~ 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/>.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#e3e3e3"
android:pathData="M360,480h240q17,0 28.5,-11.5T640,440q0,-17 -11.5,-28.5T600,400L360,400q-17,0 -28.5,11.5T320,440q0,17 11.5,28.5T360,480ZM400,620h160q17,0 28.5,-11.5T600,580q0,-17 -11.5,-28.5T560,540L400,540q-17,0 -28.5,11.5T360,580q0,17 11.5,28.5T400,620ZM280,920q-33,0 -56.5,-23.5T200,840v-720q0,-33 23.5,-56.5T280,40h400q33,0 56.5,23.5T760,120v124q18,7 29,22t11,34v80q0,19 -11,34t-29,22v404q0,33 -23.5,56.5T680,920L280,920Z" />
</vector>

View File

@@ -1,60 +1,62 @@
<resources> <resources>
<string name="app_name">Tomato</string>
<string name="start">Start</string>
<string name="stop">Stop</string>
<string name="focus">Focus</string>
<string name="short_break">Short break</string>
<string name="long_break">Long break</string>
<string name="exit">Exit</string>
<string name="skip">Skip</string>
<string name="stop_alarm">Stop alarm</string>
<string name="min_remaining_notification">%1$s min remaining</string>
<string name="paused">Paused</string>
<string name="completed">Completed</string>
<string name="up_next_notification">Up next: %1$s (%2$s)</string>
<string name="start_next">Start next</string>
<string name="choose_color_scheme">Choose color scheme</string>
<string name="ok">OK</string>
<string name="color_scheme">Color scheme</string>
<string name="dynamic">Dynamic</string>
<string name="color">Color</string>
<string name="system_default">System default</string>
<string name="alarm">Alarm</string> <string name="alarm">Alarm</string>
<string name="light">Light</string> <string name="alarm_desc">Ring alarm when a timer completes</string>
<string name="dark">Dark</string>
<string name="choose_theme">Choose theme</string>
<string name="productivity_analysis">Productivity analysis</string>
<string name="productivity_analysis_desc">Focus durations at different times of the day</string>
<string name="alarm_sound">Alarm sound</string> <string name="alarm_sound">Alarm sound</string>
<string name="always_on_display">Always On Display</string>
<string name="always_on_display_desc">Tap anywhere when viewing the timer to switch to AOD mode</string>
<string name="app_name">Tomato</string>
<string name="black_theme">Black theme</string> <string name="black_theme">Black theme</string>
<string name="black_theme_desc">Use a pure black dark theme</string> <string name="black_theme_desc">Use a pure black dark theme</string>
<string name="alarm_desc">Ring alarm when a timer completes</string> <string name="break_">Break</string>
<string name="vibrate">Vibrate</string> <string name="choose_color_scheme">Choose color scheme</string>
<string name="vibrate_desc">Vibrate when a timer completes</string> <string name="choose_theme">Choose theme</string>
<string name="theme">Theme</string> <string name="color">Color</string>
<string name="settings">Settings</string> <string name="color_scheme">Color scheme</string>
<string name="completed">Completed</string>
<string name="dark">Dark</string>
<string name="dynamic">Dynamic</string>
<string name="exit">Exit</string>
<string name="focus">Focus</string>
<string name="focus_per_day_avg">focus per day (avg)</string>
<string name="last_month">Last month</string>
<string name="last_week">Last week</string>
<string name="last_year">Last year</string>
<string name="light">Light</string>
<string name="long_break">Long break</string>
<string name="min_remaining_notification">%1$s min remaining</string>
<string name="monthly_productivity_analysis">Monthly productivity analysis</string>
<string name="more">More</string>
<string name="more_info">More info</string>
<string name="ok">OK</string>
<string name="pause">Pause</string>
<string name="paused">Paused</string>
<string name="play">Play</string>
<string name="pomodoro_info">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.</string>
<string name="productivity_analysis">Productivity analysis</string>
<string name="productivity_analysis_desc">Focus durations at different times of the day</string>
<string name="restart">Restart</string>
<string name="session_length">Session length</string> <string name="session_length">Session length</string>
<string name="session_length_desc">Focus intervals in one session: %1$d</string> <string name="session_length_desc">Focus intervals in one session: %1$d</string>
<string name="pomodoro_info">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.</string> <string name="settings">Settings</string>
<string name="stats">Stats</string> <string name="short_break">Short break</string>
<string name="today">Today</string> <string name="skip">Skip</string>
<string name="break_">Break</string>
<string name="last_week">Last week</string>
<string name="focus_per_day_avg">focus per day (avg)</string>
<string name="more_info">More info</string>
<string name="weekly_productivity_analysis">Weekly productivity analysis</string>
<string name="last_month">Last month</string>
<string name="monthly_productivity_analysis">Monthly productivity analysis</string>
<string name="stop_alarm_question">Stop Alarm?</string>
<string name="stop_alarm_dialog_text">Current timer session is complete. Tap anywhere to stop the alarm.</string>
<string name="timer_session_count">%1$d of %2$d</string>
<string name="more">More</string>
<string name="pause">Pause</string>
<string name="play">Play</string>
<string name="restart">Restart</string>
<string name="skip_to_next">Skip to next</string> <string name="skip_to_next">Skip to next</string>
<string name="up_next">Up next</string> <string name="start">Start</string>
<string name="start_next">Start next</string>
<string name="stats">Stats</string>
<string name="stop">Stop</string>
<string name="stop_alarm">Stop alarm</string>
<string name="stop_alarm_dialog_text">Current timer session is complete. Tap anywhere to stop the alarm.</string>
<string name="stop_alarm_question">Stop Alarm?</string>
<string name="system_default">System default</string>
<string name="theme">Theme</string>
<string name="timer">Timer</string> <string name="timer">Timer</string>
<string name="timer_progress">Timer progress</string> <string name="timer_progress">Timer progress</string>
<string name="last_year">Last year</string> <string name="timer_session_count">%1$d of %2$d</string>
<string name="today">Today</string>
<string name="up_next">Up next</string>
<string name="up_next_notification">Up next: %1$s (%2$s)</string>
<string name="vibrate">Vibrate</string>
<string name="vibrate_desc">Vibrate when a timer completes</string>
<string name="weekly_productivity_analysis">Weekly productivity analysis</string>
</resources> </resources>