feat(service): add snackbar to undo a timer reset

Closes #109
This commit is contained in:
Nishant Mishra
2025-12-09 12:16:23 +05:30
parent 24e0f083ee
commit 716f2114c5
7 changed files with 125 additions and 9 deletions

View File

@@ -20,6 +20,7 @@ package org.nsh07.pomodoro.data
import androidx.compose.material3.ColorScheme import androidx.compose.material3.ColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import org.nsh07.pomodoro.service.TimerStateSnapshot
import org.nsh07.pomodoro.ui.settingsScreen.viewModel.SettingsState import org.nsh07.pomodoro.ui.settingsScreen.viewModel.SettingsState
import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerState import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerState
@@ -28,4 +29,6 @@ class StateRepository {
val settingsState = MutableStateFlow(SettingsState()) val settingsState = MutableStateFlow(SettingsState())
var timerFrequency: Float = 60f var timerFrequency: Float = 60f
var colorScheme: ColorScheme = lightColorScheme() var colorScheme: ColorScheme = lightColorScheme()
var timerStateSnapshot: TimerStateSnapshot =
TimerStateSnapshot(time = 0, timerState = TimerState())
} }

View File

@@ -35,6 +35,12 @@ class ServiceHelper(private val context: Context) {
context.startService(it) context.startService(it)
} }
TimerAction.UndoReset ->
Intent(context, TimerService::class.java).also {
it.action = TimerService.Actions.UNDO_RESET.toString()
context.startService(it)
}
is TimerAction.SkipTimer -> is TimerAction.SkipTimer ->
Intent(context, TimerService::class.java).also { Intent(context, TimerService::class.java).also {
it.action = TimerService.Actions.SKIP.toString() it.action = TimerService.Actions.SKIP.toString()

View File

@@ -75,6 +75,8 @@ class TimerService : Service() {
private var lastSavedDuration = 0L private var lastSavedDuration = 0L
private val timerStateSnapshot by lazy { stateRepository.timerStateSnapshot }
private val saveLock = Mutex() private val saveLock = Mutex()
private var job = SupervisorJob() private var job = SupervisorJob()
private val timerScope = CoroutineScope(Dispatchers.IO + job) private val timerScope = CoroutineScope(Dispatchers.IO + job)
@@ -134,6 +136,8 @@ class TimerService : Service() {
} }
} }
Actions.UNDO_RESET.toString() -> undoReset()
Actions.SKIP.toString() -> skipScope.launch { skipTimer(true) } Actions.SKIP.toString() -> skipScope.launch { skipTimer(true) }
Actions.STOP_ALARM.toString() -> stopAlarm() Actions.STOP_ALARM.toString() -> stopAlarm()
@@ -326,6 +330,16 @@ class TimerService : Service() {
private suspend fun resetTimer() { private suspend fun resetTimer() {
val settingsState = _settingsState.value val settingsState = _settingsState.value
timerStateSnapshot.save(
lastSavedDuration,
time,
cycles,
startTime,
pauseTime,
pauseDuration,
_timerState.value
)
saveTimeToDb() saveTimeToDb()
lastSavedDuration = 0 lastSavedDuration = 0
time = settingsState.focusTime time = settingsState.focusTime
@@ -349,6 +363,16 @@ class TimerService : Service() {
updateProgressSegments() updateProgressSegments()
} }
private fun undoReset() {
lastSavedDuration = timerStateSnapshot.lastSavedDuration
time = timerStateSnapshot.time
cycles = timerStateSnapshot.cycles
startTime = timerStateSnapshot.startTime
pauseTime = timerStateSnapshot.pauseTime
pauseDuration = timerStateSnapshot.pauseDuration
_timerState.update { timerStateSnapshot.timerState }
}
private suspend fun skipTimer(fromButton: Boolean = false) { private suspend fun skipTimer(fromButton: Boolean = false) {
val settingsState = _settingsState.value val settingsState = _settingsState.value
saveTimeToDb() saveTimeToDb()
@@ -517,6 +541,6 @@ class TimerService : Service() {
} }
enum class Actions { enum class Actions {
TOGGLE, SKIP, RESET, STOP_ALARM, UPDATE_ALARM_TONE TOGGLE, SKIP, RESET, UNDO_RESET, STOP_ALARM, UPDATE_ALARM_TONE
} }
} }

View File

@@ -0,0 +1,48 @@
/*
* Copyright (c) 2025 Nishant Mishra
*
* This file is part of Tomato - a minimalist pomodoro timer for Android.
*
* Tomato is free software: you can redistribute it and/or modify it under the terms of the GNU
* General Public License as published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* Tomato is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tomato.
* If not, see <https://www.gnu.org/licenses/>.
*/
package org.nsh07.pomodoro.service
import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerState
data class TimerStateSnapshot(
var lastSavedDuration: Long = 0L,
var time: Long,
var cycles: Int = 0,
var startTime: Long = 0L,
var pauseTime: Long = 0L,
var pauseDuration: Long = 0L,
var timerState: TimerState
) {
fun save(
lastSavedDuration: Long,
time: Long,
cycles: Int,
startTime: Long,
pauseTime: Long,
pauseDuration: Long,
timerState: TimerState
) {
this.lastSavedDuration = lastSavedDuration
this.time = time
this.cycles = cycles
this.startTime = startTime
this.pauseTime = pauseTime
this.pauseDuration = pauseDuration
this.timerState = timerState
}
}

View File

@@ -18,6 +18,7 @@
package org.nsh07.pomodoro.ui.timerScreen package org.nsh07.pomodoro.ui.timerScreen
import android.Manifest import android.Manifest
import android.annotation.SuppressLint
import android.os.Build 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
@@ -65,6 +66,10 @@ import androidx.compose.material3.MaterialTheme.motionScheme
import androidx.compose.material3.MaterialTheme.shapes import androidx.compose.material3.MaterialTheme.shapes
import androidx.compose.material3.MaterialTheme.typography import androidx.compose.material3.MaterialTheme.typography
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
@@ -74,6 +79,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment.Companion.CenterHorizontally import androidx.compose.ui.Alignment.Companion.CenterHorizontally
@@ -83,6 +89,7 @@ import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
@@ -94,8 +101,8 @@ 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 androidx.navigation3.ui.LocalNavAnimatedContentScope
import kotlinx.coroutines.launch
import org.nsh07.pomodoro.R import org.nsh07.pomodoro.R
import org.nsh07.pomodoro.ui.mergePaddingValues
import org.nsh07.pomodoro.ui.theme.AppFonts.googleFlex600 import org.nsh07.pomodoro.ui.theme.AppFonts.googleFlex600
import org.nsh07.pomodoro.ui.theme.AppFonts.robotoFlexTopBar import org.nsh07.pomodoro.ui.theme.AppFonts.robotoFlexTopBar
import org.nsh07.pomodoro.ui.theme.TomatoTheme import org.nsh07.pomodoro.ui.theme.TomatoTheme
@@ -103,7 +110,6 @@ import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerAction
import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerMode import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerMode
import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerState import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerState
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun SharedTransitionScope.TimerScreen( fun SharedTransitionScope.TimerScreen(
@@ -115,7 +121,9 @@ fun SharedTransitionScope.TimerScreen(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val motionScheme = motionScheme val motionScheme = motionScheme
val scope = rememberCoroutineScope()
val haptic = LocalHapticFeedback.current val haptic = LocalHapticFeedback.current
val context = LocalContext.current
val color by animateColorAsState( val color by animateColorAsState(
if (timerState.timerMode == TimerMode.FOCUS) colorScheme.primary if (timerState.timerMode == TimerMode.FOCUS) colorScheme.primary
@@ -139,6 +147,7 @@ fun SharedTransitionScope.TimerScreen(
) )
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
val snackbarHostState = remember { SnackbarHostState() }
Scaffold( Scaffold(
topBar = { topBar = {
@@ -215,16 +224,16 @@ fun SharedTransitionScope.TimerScreen(
scrollBehavior = scrollBehavior scrollBehavior = scrollBehavior
) )
}, },
bottomBar = { Spacer(Modifier.height(contentPadding.calculateBottomPadding())) },
snackbarHost = { SnackbarHost(snackbarHostState) },
modifier = modifier modifier = modifier
.nestedScroll(scrollBehavior.nestedScrollConnection) .nestedScroll(scrollBehavior.nestedScrollConnection)
) { innerPadding -> ) { innerPadding ->
val insets = mergePaddingValues(innerPadding, contentPadding)
LazyColumn( LazyColumn(
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = CenterHorizontally, horizontalAlignment = CenterHorizontally,
contentPadding = insets, contentPadding = innerPadding,
modifier = Modifier modifier = Modifier.fillMaxSize()
.fillMaxSize()
) { ) {
item { item {
Column(horizontalAlignment = CenterHorizontally) { Column(horizontalAlignment = CenterHorizontally) {
@@ -411,6 +420,19 @@ fun SharedTransitionScope.TimerScreen(
onClick = { onClick = {
onAction(TimerAction.ResetTimer) onAction(TimerAction.ResetTimer)
haptic.performHapticFeedback(HapticFeedbackType.VirtualKey) haptic.performHapticFeedback(HapticFeedbackType.VirtualKey)
@SuppressLint("LocalContextGetResourceValueCall")
scope.launch {
val result = snackbarHostState.showSnackbar(
context.getString(R.string.timer_reset_message),
actionLabel = context.getString(R.string.undo),
withDismissAction = true,
duration = SnackbarDuration.Long
)
if (result == SnackbarResult.ActionPerformed) {
onAction(TimerAction.UndoReset)
}
}
}, },
colors = IconButtonDefaults.filledTonalIconButtonColors( colors = IconButtonDefaults.filledTonalIconButtonColors(
containerColor = colorContainer containerColor = colorContainer

View File

@@ -1,8 +1,18 @@
/* /*
* Copyright (c) 2025 Nishant Mishra * Copyright (c) 2025 Nishant Mishra
* *
* You should have received a copy of the GNU General Public License * This file is part of Tomato - a minimalist pomodoro timer for Android.
* along with this program. If not, see <https://www.gnu.org/licenses/>. *
* Tomato is free software: you can redistribute it and/or modify it under the terms of the GNU
* General Public License as published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* Tomato is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tomato.
* If not, see <https://www.gnu.org/licenses/>.
*/ */
package org.nsh07.pomodoro.ui.timerScreen.viewModel package org.nsh07.pomodoro.ui.timerScreen.viewModel
@@ -11,6 +21,7 @@ sealed interface TimerAction {
data class SkipTimer(val fromButton: Boolean) : TimerAction data class SkipTimer(val fromButton: Boolean) : TimerAction
data object ResetTimer : TimerAction data object ResetTimer : TimerAction
data object UndoReset : TimerAction
data object StopAlarm : TimerAction data object StopAlarm : TimerAction
data object ToggleTimer : TimerAction data object ToggleTimer : TimerAction
} }

View File

@@ -111,4 +111,6 @@
<string name="auto_start_next_timer">Auto start next timer</string> <string name="auto_start_next_timer">Auto start next timer</string>
<string name="secure_aod">Secure AOD</string> <string name="secure_aod">Secure AOD</string>
<string name="secure_aod_desc">Automatically lock your device after a timeout, while keeping the AOD visible</string> <string name="secure_aod_desc">Automatically lock your device after a timeout, while keeping the AOD visible</string>
<string name="timer_reset_message">Timer reset</string>
<string name="undo">Undo</string>
</resources> </resources>