From d37c26c7789b71d94d296b9c876507acd4392e22 Mon Sep 17 00:00:00 2001 From: Nishant Mishra Date: Wed, 27 Aug 2025 08:30:59 +0530 Subject: [PATCH 01/17] feat: Make stats screen scroll edge-to-edge --- .../main/java/org/nsh07/pomodoro/ui/AppScreen.kt | 1 + .../nsh07/pomodoro/ui/statsScreen/StatsScreen.kt | 14 ++++++++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) 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 6a61814..dadfe37 100644 --- a/app/src/main/java/org/nsh07/pomodoro/ui/AppScreen.kt +++ b/app/src/main/java/org/nsh07/pomodoro/ui/AppScreen.kt @@ -160,6 +160,7 @@ fun AppScreen( entry { StatsScreenRoot( + contentPadding = contentPadding, viewModel = statsViewModel, modifier = modifier.padding( start = contentPadding.calculateStartPadding(layoutDirection), diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/StatsScreen.kt b/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/StatsScreen.kt index 5c9ad6c..44eec0c 100644 --- a/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/StatsScreen.kt +++ b/app/src/main/java/org/nsh07/pomodoro/ui/statsScreen/StatsScreen.kt @@ -12,8 +12,10 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -64,6 +66,7 @@ import org.nsh07.pomodoro.utils.millisecondsToHoursMinutes @Composable fun StatsScreenRoot( + contentPadding: PaddingValues, modifier: Modifier = Modifier, viewModel: StatsViewModel = viewModel(factory = StatsViewModel.Factory) ) { @@ -74,6 +77,7 @@ fun StatsScreenRoot( .lastMonthAverageFocusTimes.collectAsStateWithLifecycle(null) StatsScreen( + contentPadding = contentPadding, lastWeekSummaryChartData = remember { viewModel.lastWeekSummaryChartData }, lastWeekSummaryAnalysisModelProducer = remember { viewModel.lastWeekSummaryAnalysisModelProducer }, lastMonthSummaryChartData = remember { viewModel.lastMonthSummaryChartData }, @@ -88,6 +92,7 @@ fun StatsScreenRoot( @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun StatsScreen( + contentPadding: PaddingValues, lastWeekSummaryChartData: Pair>>, lastWeekSummaryAnalysisModelProducer: CartesianChartModelProducer, lastMonthSummaryChartData: Pair>>, @@ -114,12 +119,16 @@ fun StatsScreen( fontFamily = robotoFlexTopBar, fontSize = 32.sp, lineHeight = 32.sp - ) + ), + modifier = Modifier + .padding(top = contentPadding.calculateTopPadding()) + .padding(vertical = 14.dp) ) }, subtitle = {}, titleHorizontalAlignment = Alignment.CenterHorizontally, - scrollBehavior = scrollBehavior + scrollBehavior = scrollBehavior, + windowInsets = WindowInsets() ) LazyColumn( @@ -356,6 +365,7 @@ fun StatsScreenPreview() { } StatsScreen( + PaddingValues(), Pair(modelProducer, ExtraStore.Key()), modelProducer, Pair(modelProducer, ExtraStore.Key()), From 1023fd53d849df9bf4d41dc53434a9b4b8a30dc4 Mon Sep 17 00:00:00 2001 From: Nishant Mishra Date: Tue, 2 Sep 2025 20:31:18 +0530 Subject: [PATCH 02/17] feat: Update list item colors to more closely match M3 expressive spec --- .../ui/settingsScreen/MinuteInputField.kt | 3 ++- .../ui/settingsScreen/SettingsScreen.kt | 7 ++++-- .../java/org/nsh07/pomodoro/ui/theme/Color.kt | 23 ++++++++++++++++++- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/MinuteInputField.kt b/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/MinuteInputField.kt index 81a8c21..6a55afc 100644 --- a/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/MinuteInputField.kt +++ b/app/src/main/java/org/nsh07/pomodoro/ui/settingsScreen/MinuteInputField.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.nsh07.pomodoro.ui.theme.AppFonts.openRundeClock +import org.nsh07.pomodoro.ui.theme.CustomColors.listItemColors @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -59,7 +60,7 @@ fun MinuteInputField( .background( animateColorAsState( if (state.text.isNotEmpty()) - colorScheme.surface + listItemColors.containerColor else colorScheme.errorContainer, motionScheme.defaultEffectsSpec() ).value, 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 496b3ef..5355f9c 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 @@ -60,6 +60,8 @@ import androidx.lifecycle.viewmodel.compose.viewModel import org.nsh07.pomodoro.R import org.nsh07.pomodoro.ui.settingsScreen.viewModel.SettingsViewModel import org.nsh07.pomodoro.ui.theme.AppFonts.robotoFlexTopBar +import org.nsh07.pomodoro.ui.theme.CustomColors.listItemColors +import org.nsh07.pomodoro.ui.theme.CustomColors.topBarColors import org.nsh07.pomodoro.ui.theme.TomatoTheme @OptIn(ExperimentalMaterial3Api::class) @@ -120,7 +122,7 @@ private fun SettingsScreen( ) }, subtitle = {}, - colors = TopAppBarDefaults.topAppBarColors(containerColor = colorScheme.surfaceContainer), + colors = topBarColors, titleHorizontalAlignment = Alignment.CenterHorizontally, scrollBehavior = scrollBehavior ) @@ -128,7 +130,7 @@ private fun SettingsScreen( LazyColumn( verticalArrangement = Arrangement.spacedBy(2.dp), modifier = Modifier - .background(colorScheme.surfaceContainer) + .background(topBarColors.containerColor) .fillMaxSize() .padding(horizontal = 16.dp) ) { @@ -221,6 +223,7 @@ private fun SettingsScreen( ) } }, + colors = listItemColors, modifier = Modifier.clip(shapes.large) ) } diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/theme/Color.kt b/app/src/main/java/org/nsh07/pomodoro/ui/theme/Color.kt index 94eb4ba..bf8ee3a 100644 --- a/app/src/main/java/org/nsh07/pomodoro/ui/theme/Color.kt +++ b/app/src/main/java/org/nsh07/pomodoro/ui/theme/Color.kt @@ -1,5 +1,12 @@ package org.nsh07.pomodoro.ui.theme +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ListItemColors +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.TopAppBarColors +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color val Purple80 = Color(0xFFD0BCFF) @@ -8,4 +15,18 @@ val Pink80 = Color(0xFFEFB8C8) val Purple40 = Color(0xFF6650a4) val PurpleGrey40 = Color(0xFF625b71) -val Pink40 = Color(0xFF7D5260) \ No newline at end of file +val Pink40 = Color(0xFF7D5260) + +object CustomColors { + @OptIn(ExperimentalMaterial3Api::class) + val topBarColors: TopAppBarColors + @Composable get() { + return TopAppBarDefaults.topAppBarColors( + containerColor = colorScheme.surfaceContainer, + scrolledContainerColor = colorScheme.surfaceContainer + ) + } + + val listItemColors: ListItemColors + @Composable get() = ListItemDefaults.colors(containerColor = colorScheme.surfaceBright) +} \ No newline at end of file From 8bcf246d1fb7bc95a27010ef098431f16ca8b9f7 Mon Sep 17 00:00:00 2001 From: Nishant Mishra Date: Tue, 2 Sep 2025 20:55:11 +0530 Subject: [PATCH 03/17] feat: Make the timer clickable to show the current focus interval count Focus interval count shown is not live, this will be implemented in the next commit. #21 --- .../pomodoro/ui/timerScreen/TimerScreen.kt | 66 ++++++++++++++----- 1 file changed, 51 insertions(+), 15 deletions(-) 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 4c492c1..4f7ccec 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 @@ -12,10 +12,16 @@ import android.os.Build 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.animateColorAsState +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith +import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -41,14 +47,20 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.MaterialTheme.motionScheme +import androidx.compose.material3.MaterialTheme.shapes import androidx.compose.material3.MaterialTheme.typography import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +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.Alignment.Companion.CenterHorizontally import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.platform.LocalDensity @@ -170,15 +182,15 @@ fun TimerScreen( } }, subtitle = {}, - titleHorizontalAlignment = Alignment.CenterHorizontally + titleHorizontalAlignment = CenterHorizontally ) Column( verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, + horizontalAlignment = CenterHorizontally, modifier = Modifier.fillMaxSize() ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { + Column(horizontalAlignment = CenterHorizontally) { Box(contentAlignment = Alignment.Center) { if (timerState.timerMode == TimerMode.FOCUS) { CircularProgressIndicator( @@ -217,17 +229,41 @@ fun TimerScreen( gapSize = 16.dp ) } - Text( - text = timerState.timeStr, - style = TextStyle( - fontFamily = openRundeClock, - fontWeight = FontWeight.Bold, - fontSize = 72.sp, - letterSpacing = (-2).sp - ), - textAlign = TextAlign.Center, - maxLines = 1 - ) + var expanded by remember { mutableStateOf(timerState.showBrandTitle) } + Column( + horizontalAlignment = CenterHorizontally, + modifier = Modifier + .clip(shapes.largeIncreased) + .clickable(onClick = { expanded = !expanded }) + ) { + LaunchedEffect(timerState.showBrandTitle) { + expanded = timerState.showBrandTitle + } + Text( + text = timerState.timeStr, + style = TextStyle( + fontFamily = openRundeClock, + fontWeight = FontWeight.Bold, + fontSize = 72.sp, + letterSpacing = (-2).sp + ), + textAlign = TextAlign.Center, + maxLines = 1 + ) + AnimatedVisibility( + expanded, + enter = fadeIn(motionScheme.defaultEffectsSpec()) + + expandVertically(motionScheme.defaultSpatialSpec()), + exit = fadeOut(motionScheme.defaultEffectsSpec()) + + shrinkVertically(motionScheme.defaultSpatialSpec()) + ) { + Text( + "1 of 4", + fontFamily = openRundeClock, + style = typography.titleLarge + ) + } + } } val interactionSources = remember { List(3) { MutableInteractionSource() } } ButtonGroup( @@ -393,7 +429,7 @@ fun TimerScreen( Spacer(Modifier.height(32.dp)) - Column(horizontalAlignment = Alignment.CenterHorizontally) { + Column(horizontalAlignment = CenterHorizontally) { Text("Up next", style = typography.titleSmall) Text( timerState.nextTimeStr, From ad8929217bb9dc22e9a54be591d296287be51741 Mon Sep 17 00:00:00 2001 From: Nishant Mishra Date: Mon, 8 Sep 2025 10:23:53 +0530 Subject: [PATCH 04/17] feat: Show current interval focus count when tapping the clock #21 --- .../java/org/nsh07/pomodoro/ui/timerScreen/TimerScreen.kt | 5 +++-- .../nsh07/pomodoro/ui/timerScreen/viewModel/TimerState.kt | 4 +++- .../pomodoro/ui/timerScreen/viewModel/TimerViewModel.kt | 8 ++++++-- 3 files changed, 12 insertions(+), 5 deletions(-) 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 4f7ccec..4dbb7c8 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 @@ -258,9 +258,10 @@ fun TimerScreen( shrinkVertically(motionScheme.defaultSpatialSpec()) ) { Text( - "1 of 4", + "${timerState.currentFocusCount} of ${timerState.totalFocusCount}", fontFamily = openRundeClock, - style = typography.titleLarge + style = typography.titleLarge, + color = colorScheme.outline ) } } diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerState.kt b/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerState.kt index f0ead74..4a9c3d9 100644 --- a/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerState.kt +++ b/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerState.kt @@ -14,7 +14,9 @@ data class TimerState( val timerRunning: Boolean = false, val nextTimerMode: TimerMode = TimerMode.SHORT_BREAK, val nextTimeStr: String = "5:00", - val showBrandTitle: Boolean = true + val showBrandTitle: Boolean = true, + val currentFocusCount: Int = 1, + val totalFocusCount: Int = 4 ) enum class TimerMode { 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 74cc8ac..8f7d025 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 @@ -145,7 +145,9 @@ class TimerViewModel( timeStr = millisecondsToStr(time.value), totalTime = time.value, nextTimerMode = if (timerRepository.sessionLength > 1) TimerMode.SHORT_BREAK else TimerMode.LONG_BREAK, - nextTimeStr = millisecondsToStr(if (timerRepository.sessionLength > 1) timerRepository.shortBreakTime else timerRepository.longBreakTime) + nextTimeStr = millisecondsToStr(if (timerRepository.sessionLength > 1) timerRepository.shortBreakTime else timerRepository.longBreakTime), + currentFocusCount = 1, + totalFocusCount = timerRepository.sessionLength ) } } @@ -173,7 +175,9 @@ class TimerViewModel( timerRepository.longBreakTime ) else millisecondsToStr( timerRepository.shortBreakTime - ) + ), + currentFocusCount = cycles / 2 + 1, + totalFocusCount = timerRepository.sessionLength ) } } else { From 99cbb46dca0e61320ffe7a7c662de4d378f3208f Mon Sep 17 00:00:00 2001 From: Nishant Mishra Date: Mon, 8 Sep 2025 11:00:06 +0530 Subject: [PATCH 05/17] feat: Implement alarm playback when timer is complete Closes: #15 --- .../pomodoro/ui/timerScreen/AlarmDialog.kt | 77 +++++++++++++++++++ .../pomodoro/ui/timerScreen/TimerScreen.kt | 3 + .../ui/timerScreen/viewModel/TimerAction.kt | 1 + .../ui/timerScreen/viewModel/TimerState.kt | 3 +- .../timerScreen/viewModel/TimerViewModel.kt | 33 +++++++- app/src/main/res/drawable/alarm.xml | 16 ++++ 6 files changed, 129 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/AlarmDialog.kt create mode 100644 app/src/main/res/drawable/alarm.xml diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/AlarmDialog.kt b/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/AlarmDialog.kt new file mode 100644 index 0000000..556e88d --- /dev/null +++ b/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/AlarmDialog.kt @@ -0,0 +1,77 @@ +/* + * 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.timerScreen + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MaterialTheme.typography +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import org.nsh07.pomodoro.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AlarmDialog( + modifier: Modifier = Modifier, + stopAlarm: () -> Unit +) { + BasicAlertDialog( + onDismissRequest = stopAlarm, + modifier = modifier + ) { + Surface( + modifier = Modifier + .wrapContentWidth() + .wrapContentHeight() + .clickable(onClick = stopAlarm), + shape = MaterialTheme.shapes.extraLarge, + tonalElevation = AlertDialogDefaults.TonalElevation, + ) { + Column(modifier = Modifier.padding(24.dp)) { + Icon( + painter = painterResource(R.drawable.alarm), + contentDescription = "Alarm", + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + Spacer(Modifier.height(16.dp)) + Text( + text = "Stop Alarm?", + style = typography.headlineSmall, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + Spacer(Modifier.height(16.dp)) + Text( + text = "Current timer session is complete. Tap anywhere to stop the alarm." + ) + Spacer(modifier = Modifier.height(24.dp)) + Button( + onClick = stopAlarm, + modifier = Modifier.align(Alignment.End), + ) { + Text("Ok") + } + } + } + } +} \ No newline at end of file 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 4dbb7c8..a31724e 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 @@ -111,6 +111,9 @@ fun TimerScreen( onResult = {} ) + if (timerState.alarmRinging) + AlarmDialog { onAction(TimerAction.StopAlarm) } + Column(modifier = modifier) { TopAppBar( title = { diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerAction.kt b/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerAction.kt index 633c5cb..46df0d5 100644 --- a/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerAction.kt +++ b/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerAction.kt @@ -10,5 +10,6 @@ package org.nsh07.pomodoro.ui.timerScreen.viewModel sealed interface TimerAction { data object ResetTimer : TimerAction data object SkipTimer : TimerAction + data object StopAlarm : TimerAction data object ToggleTimer : TimerAction } \ No newline at end of file diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerState.kt b/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerState.kt index 4a9c3d9..3a3cd3b 100644 --- a/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerState.kt +++ b/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerState.kt @@ -16,7 +16,8 @@ data class TimerState( val nextTimeStr: String = "5:00", val showBrandTitle: Boolean = true, val currentFocusCount: Int = 1, - val totalFocusCount: Int = 4 + val totalFocusCount: Int = 4, + val alarmRinging: Boolean = false ) enum class TimerMode { 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 8f7d025..80ed086 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 @@ -8,19 +8,23 @@ package org.nsh07.pomodoro.ui.timerScreen.viewModel import android.annotation.SuppressLint +import android.app.Application import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.content.Intent +import android.media.MediaPlayer import android.os.SystemClock +import android.provider.Settings import androidx.compose.material3.ColorScheme import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat -import androidx.lifecycle.ViewModel +import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY +import androidx.lifecycle.application import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory @@ -46,12 +50,13 @@ import kotlin.text.Typography.middleDot @OptIn(FlowPreview::class) class TimerViewModel( + application: Application, private val preferenceRepository: PreferenceRepository, private val statRepository: StatRepository, private val timerRepository: TimerRepository, private val notificationBuilder: NotificationCompat.Builder, private val notificationManager: NotificationManagerCompat -) : ViewModel() { +) : AndroidViewModel(application) { private val _timerState = MutableStateFlow( TimerState( totalTime = timerRepository.focusTime, @@ -73,6 +78,11 @@ class TimerViewModel( private lateinit var cs: ColorScheme + private val alarm = MediaPlayer.create( + this.application, + Settings.System.DEFAULT_ALARM_ALERT_URI ?: Settings.System.DEFAULT_RINGTONE_URI + ) + init { viewModelScope.launch(Dispatchers.IO) { timerRepository.focusTime = @@ -126,6 +136,7 @@ class TimerViewModel( when (action) { TimerAction.ResetTimer -> resetTimer() TimerAction.SkipTimer -> skipTimer() + TimerAction.StopAlarm -> stopAlarm() TimerAction.ToggleTimer -> toggleTimer() } } @@ -331,9 +342,24 @@ class TimerViewModel( ) .setShowWhen(true) .setWhen(System.currentTimeMillis() + remainingTime) // Sets the Live Activity/Now Bar chip time - .setSilent(!complete) + .setSilent(true) .build() ) + + if (complete) { + alarm.start() + _timerState.update { currentState -> + currentState.copy(alarmRinging = true) + } + } + } + + fun stopAlarm() { + alarm.pause() + alarm.seekTo(0) + _timerState.update { currentState -> + currentState.copy(alarmRinging = false) + } } companion object { @@ -374,6 +400,7 @@ class TimerViewModel( .setOngoing(true) TimerViewModel( + application = application, preferenceRepository = appPreferenceRepository, statRepository = appStatRepository, timerRepository = appTimerRepository, diff --git a/app/src/main/res/drawable/alarm.xml b/app/src/main/res/drawable/alarm.xml new file mode 100644 index 0000000..500f240 --- /dev/null +++ b/app/src/main/res/drawable/alarm.xml @@ -0,0 +1,16 @@ + + + + + From fd38d2768ea40ba4811f44c05c2976bc19392085 Mon Sep 17 00:00:00 2001 From: Nishant Mishra Date: Mon, 8 Sep 2025 19:06:15 +0530 Subject: [PATCH 06/17] build: Enable R8 to significantly reduce apk size Also disable obfuscation to maintain reproducible builds --- app/build.gradle.kts | 3 ++- app/proguard-rules.pro | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 090bdcb..d28b7bd 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -41,7 +41,8 @@ android { buildTypes { release { - isMinifyEnabled = false + isMinifyEnabled = true + isShrinkResources = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 481bb43..f5fb65b 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -18,4 +18,6 @@ # If you keep the line number information, uncomment this to # hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +#-renamesourcefileattribute SourceFile + +-dontobfuscate \ No newline at end of file From bec64cbdda8fc07950c75946cf743f95970a794e Mon Sep 17 00:00:00 2001 From: Nishant Mishra Date: Mon, 8 Sep 2025 19:07:47 +0530 Subject: [PATCH 07/17] fix: Don't play alarm sound when skip button is clicked --- .../java/org/nsh07/pomodoro/ui/timerScreen/TimerScreen.kt | 4 ++-- .../nsh07/pomodoro/ui/timerScreen/viewModel/TimerAction.kt | 3 ++- .../pomodoro/ui/timerScreen/viewModel/TimerViewModel.kt | 7 ++++--- 3 files changed, 8 insertions(+), 6 deletions(-) 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 a31724e..dae0e4e 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 @@ -395,7 +395,7 @@ fun TimerScreen( customItem( { FilledTonalIconButton( - onClick = { onAction(TimerAction.SkipTimer) }, + onClick = { onAction(TimerAction.SkipTimer(fromButton = true)) }, colors = IconButtonDefaults.filledTonalIconButtonColors( containerColor = colorContainer ), @@ -422,7 +422,7 @@ fun TimerScreen( }, text = { Text("Skip to next") }, onClick = { - onAction(TimerAction.SkipTimer) + onAction(TimerAction.SkipTimer(fromButton = true)) state.dismiss() } ) diff --git a/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerAction.kt b/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerAction.kt index 46df0d5..2134c1c 100644 --- a/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerAction.kt +++ b/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerAction.kt @@ -8,8 +8,9 @@ package org.nsh07.pomodoro.ui.timerScreen.viewModel sealed interface TimerAction { + data class SkipTimer(val fromButton: Boolean) : TimerAction + data object ResetTimer : TimerAction - data object SkipTimer : TimerAction data object StopAlarm : TimerAction data object ToggleTimer : TimerAction } \ No newline at end of file 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 80ed086..ebfd45e 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 @@ -134,8 +134,9 @@ class TimerViewModel( fun onAction(action: TimerAction) { when (action) { + is TimerAction.SkipTimer -> skipTimer(action.fromButton) + TimerAction.ResetTimer -> resetTimer() - TimerAction.SkipTimer -> skipTimer() TimerAction.StopAlarm -> stopAlarm() TimerAction.ToggleTimer -> toggleTimer() } @@ -164,10 +165,10 @@ class TimerViewModel( } } - private fun skipTimer() { + private fun skipTimer(fromButton: Boolean = false) { viewModelScope.launch { saveTimeToDb() - showTimerNotification(0, paused = true, complete = true) + showTimerNotification(0, paused = true, complete = !fromButton) startTime = 0L pauseTime = 0L pauseDuration = 0L From 90fa94e06509fb14875ac6d6ca0885a0876a039b Mon Sep 17 00:00:00 2001 From: Nishant Mishra Date: Sun, 14 Sep 2025 12:39:27 +0530 Subject: [PATCH 08/17] feat: Implement a foreground service to run the timer This fixes many issues that were occurring because of the app being killed in the background Closes: #31 --- app/src/main/AndroidManifest.xml | 9 + .../org/nsh07/pomodoro/TomatoApplication.kt | 10 + .../org/nsh07/pomodoro/data/AppContainer.kt | 39 ++ .../nsh07/pomodoro/service/TimerService.kt | 346 ++++++++++++++++++ .../java/org/nsh07/pomodoro/ui/AppScreen.kt | 40 +- .../timerScreen/viewModel/TimerViewModel.kt | 269 +------------- 6 files changed, 448 insertions(+), 265 deletions(-) create mode 100644 app/src/main/java/org/nsh07/pomodoro/service/TimerService.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9dc04d4..a5372a3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,8 @@ + + @@ -26,6 +28,13 @@ + + + \ No newline at end of file diff --git a/app/src/main/java/org/nsh07/pomodoro/TomatoApplication.kt b/app/src/main/java/org/nsh07/pomodoro/TomatoApplication.kt index b0e144f..e1902bc 100644 --- a/app/src/main/java/org/nsh07/pomodoro/TomatoApplication.kt +++ b/app/src/main/java/org/nsh07/pomodoro/TomatoApplication.kt @@ -1,6 +1,8 @@ package org.nsh07.pomodoro import android.app.Application +import android.app.NotificationChannel +import android.app.NotificationManager import org.nsh07.pomodoro.data.AppContainer import org.nsh07.pomodoro.data.DefaultAppContainer @@ -9,5 +11,13 @@ class TomatoApplication : Application() { override fun onCreate() { super.onCreate() container = DefaultAppContainer(this) + + val notificationChannel = NotificationChannel( + "timer", + "Timer progress", + NotificationManager.IMPORTANCE_HIGH + ) + + container.notificationManager.createNotificationChannel(notificationChannel) } } \ No newline at end of file diff --git a/app/src/main/java/org/nsh07/pomodoro/data/AppContainer.kt b/app/src/main/java/org/nsh07/pomodoro/data/AppContainer.kt index 2d45910..51c3570 100644 --- a/app/src/main/java/org/nsh07/pomodoro/data/AppContainer.kt +++ b/app/src/main/java/org/nsh07/pomodoro/data/AppContainer.kt @@ -8,11 +8,23 @@ package org.nsh07.pomodoro.data import android.content.Context +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import kotlinx.coroutines.flow.MutableStateFlow +import org.nsh07.pomodoro.R +import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerState +import org.nsh07.pomodoro.utils.millisecondsToStr interface AppContainer { val appPreferenceRepository: AppPreferenceRepository val appStatRepository: AppStatRepository val appTimerRepository: AppTimerRepository + val notificationManager: NotificationManagerCompat + val notificationBuilder: NotificationCompat.Builder + val timerState: MutableStateFlow + val time: MutableStateFlow } class DefaultAppContainer(context: Context) : AppContainer { @@ -27,4 +39,31 @@ class DefaultAppContainer(context: Context) : AppContainer { override val appTimerRepository: AppTimerRepository by lazy { AppTimerRepository() } + override val notificationManager: NotificationManagerCompat by lazy { + NotificationManagerCompat.from(context) + } + + override val notificationBuilder: NotificationCompat.Builder by lazy { + NotificationCompat.Builder(context, "timer") + .setSmallIcon(R.drawable.tomato_logo_notification) + .setOngoing(true) + .setColor(Color.Red.toArgb()) + .setRequestPromotedOngoing(true) + .setOngoing(true) + } + + override val timerState: MutableStateFlow by lazy { + MutableStateFlow( + TimerState( + totalTime = appTimerRepository.focusTime, + timeStr = millisecondsToStr(appTimerRepository.focusTime), + nextTimeStr = millisecondsToStr(appTimerRepository.shortBreakTime) + ) + ) + } + + override val time: MutableStateFlow by lazy { + MutableStateFlow(appTimerRepository.focusTime) + } + } \ No newline at end of file diff --git a/app/src/main/java/org/nsh07/pomodoro/service/TimerService.kt b/app/src/main/java/org/nsh07/pomodoro/service/TimerService.kt new file mode 100644 index 0000000..9e94929 --- /dev/null +++ b/app/src/main/java/org/nsh07/pomodoro/service/TimerService.kt @@ -0,0 +1,346 @@ +package org.nsh07.pomodoro.service + +import android.annotation.SuppressLint +import android.app.Service +import android.content.Intent +import android.media.MediaPlayer +import android.os.IBinder +import android.os.SystemClock +import android.provider.Settings +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.ui.graphics.toArgb +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.nsh07.pomodoro.TomatoApplication +import org.nsh07.pomodoro.data.AppContainer +import org.nsh07.pomodoro.data.StatRepository +import org.nsh07.pomodoro.data.TimerRepository +import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerMode +import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerState +import org.nsh07.pomodoro.utils.millisecondsToStr +import kotlin.text.Typography.middleDot + +@ExperimentalAnimationApi +class TimerService : Service() { + private lateinit var appContainer: AppContainer + + private lateinit var timerRepository: TimerRepository + private lateinit var statRepository: StatRepository + private lateinit var notificationManager: NotificationManagerCompat + private lateinit var notificationBuilder: NotificationCompat.Builder + private lateinit var _timerState: MutableStateFlow + private lateinit var _time: MutableStateFlow + + val timeStateFlow by lazy { + _time.asStateFlow() + } + + var time: Long + get() = timeStateFlow.value + set(value) = _time.update { value } + + lateinit var timerState: StateFlow + + private var cycles = 0 + private var startTime = 0L + private var pauseTime = 0L + private var pauseDuration = 0L + + private val timerJob = SupervisorJob() + private val scope = CoroutineScope(Dispatchers.IO + timerJob) + private val skipScope = CoroutineScope(Dispatchers.IO) + + private lateinit var alarm: MediaPlayer + + private var cs: ColorScheme = lightColorScheme() + + override fun onBind(intent: Intent?): IBinder? { + return null + } + + override fun onCreate() { + appContainer = (application as TomatoApplication).container + timerRepository = appContainer.appTimerRepository + statRepository = appContainer.appStatRepository + notificationManager = NotificationManagerCompat.from(this) + notificationBuilder = appContainer.notificationBuilder + _timerState = appContainer.timerState + _time = appContainer.time + + timerState = _timerState.asStateFlow() + + alarm = MediaPlayer.create( + this, + Settings.System.DEFAULT_ALARM_ALERT_URI ?: Settings.System.DEFAULT_RINGTONE_URI + ) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + Actions.TOGGLE.toString() -> { + startForegroundService() + toggleTimer() + } + + Actions.RESET.toString() -> { + if (timerState.value.timerRunning) toggleTimer() + resetTimer() + stopForegroundService() + } + + Actions.SKIP.toString() -> skipTimer(true) + + Actions.STOP_ALARM.toString() -> stopAlarm() + } + return super.onStartCommand(intent, flags, startId) + } + + private fun toggleTimer() { + if (timerState.value.timerRunning) { + showTimerNotification(time.toInt(), paused = true) + _timerState.update { currentState -> + currentState.copy(timerRunning = false) + } + pauseTime = SystemClock.elapsedRealtime() + } else { + _timerState.update { it.copy(timerRunning = true) } + if (pauseTime != 0L) pauseDuration += SystemClock.elapsedRealtime() - pauseTime + + var iterations = -1 + + scope.launch { + while (true) { + if (!timerState.value.timerRunning) break + if (startTime == 0L) startTime = SystemClock.elapsedRealtime() + + time = when (timerState.value.timerMode) { + TimerMode.FOCUS -> timerRepository.focusTime - (SystemClock.elapsedRealtime() - startTime - pauseDuration).toInt() + + TimerMode.SHORT_BREAK -> timerRepository.shortBreakTime - (SystemClock.elapsedRealtime() - startTime - pauseDuration).toInt() + + else -> timerRepository.longBreakTime - (SystemClock.elapsedRealtime() - startTime - pauseDuration).toInt() + } + + iterations = (iterations + 1) % 50 + + if (iterations == 0) showTimerNotification(time.toInt()) + + if (time < 0) { + skipTimer() + + _timerState.update { currentState -> + currentState.copy(timerRunning = false) + } + timerJob.cancel() + } else { + _timerState.update { currentState -> + currentState.copy( + timeStr = millisecondsToStr(time) + ) + } + } + + delay(100) + } + } + } + } + + @SuppressLint("MissingPermission") // We check for the permission when pressing the Play button in the UI + fun showTimerNotification( + remainingTime: Int, paused: Boolean = false, complete: Boolean = false + ) { + val totalTime = when (timerState.value.timerMode) { + TimerMode.FOCUS -> timerRepository.focusTime.toInt() + TimerMode.SHORT_BREAK -> timerRepository.shortBreakTime.toInt() + else -> timerRepository.longBreakTime.toInt() + } + + val currentTimer = when (timerState.value.timerMode) { + TimerMode.FOCUS -> "Focus" + TimerMode.SHORT_BREAK -> "Short break" + else -> "Long break" + } + + val nextTimer = when (timerState.value.nextTimerMode) { + TimerMode.FOCUS -> "Focus" + TimerMode.SHORT_BREAK -> "Short break" + else -> "Long break" + } + + val remainingTimeString = if ((remainingTime.toFloat() / 60000f) < 1.0f) "< 1" + else (remainingTime.toFloat() / 60000f).toInt() + + notificationManager.notify( + 1, + notificationBuilder + .setContentTitle( + if (!complete) { + "$currentTimer $middleDot $remainingTimeString min remaining" + if (paused) " $middleDot Paused" else "" + } else "$currentTimer $middleDot Completed" + ) + .setContentText("Up next: $nextTimer (${timerState.value.nextTimeStr})") + .setStyle( + NotificationCompat.ProgressStyle().also { + // Add all the Focus, Short break and long break intervals in order + for (i in 0.. + currentState.copy(alarmRinging = true) + } + } + } + + private fun resetTimer() { + skipScope.launch { + saveTimeToDb() + time = timerRepository.focusTime + cycles = 0 + startTime = 0L + pauseTime = 0L + pauseDuration = 0L + + _timerState.update { currentState -> + currentState.copy( + timerMode = TimerMode.FOCUS, + timeStr = millisecondsToStr(time), + totalTime = time, + nextTimerMode = if (timerRepository.sessionLength > 1) TimerMode.SHORT_BREAK else TimerMode.LONG_BREAK, + nextTimeStr = millisecondsToStr(if (timerRepository.sessionLength > 1) timerRepository.shortBreakTime else timerRepository.longBreakTime), + currentFocusCount = 1, + totalFocusCount = timerRepository.sessionLength + ) + } + } + } + + private fun skipTimer(fromButton: Boolean = false) { + skipScope.launch { + saveTimeToDb() + showTimerNotification(0, paused = true, complete = !fromButton) + startTime = 0L + pauseTime = 0L + pauseDuration = 0L + + cycles = (cycles + 1) % (timerRepository.sessionLength * 2) + + if (cycles % 2 == 0) { + time = timerRepository.focusTime + _timerState.update { currentState -> + currentState.copy( + timerMode = TimerMode.FOCUS, + timeStr = millisecondsToStr(time), + totalTime = time, + nextTimerMode = if (cycles == (timerRepository.sessionLength - 1) * 2) TimerMode.LONG_BREAK else TimerMode.SHORT_BREAK, + nextTimeStr = if (cycles == (timerRepository.sessionLength - 1) * 2) millisecondsToStr( + timerRepository.longBreakTime + ) else millisecondsToStr( + timerRepository.shortBreakTime + ), + currentFocusCount = cycles / 2 + 1, + totalFocusCount = timerRepository.sessionLength + ) + } + } else { + val long = cycles == (timerRepository.sessionLength * 2) - 1 + time = if (long) timerRepository.longBreakTime else timerRepository.shortBreakTime + + _timerState.update { currentState -> + currentState.copy( + timerMode = if (long) TimerMode.LONG_BREAK else TimerMode.SHORT_BREAK, + timeStr = millisecondsToStr(time), + totalTime = time, + nextTimerMode = TimerMode.FOCUS, + nextTimeStr = millisecondsToStr(timerRepository.focusTime) + ) + } + } + } + } + + fun stopAlarm() { + alarm.pause() + alarm.seekTo(0) + _timerState.update { currentState -> + currentState.copy(alarmRinging = false) + } + } + + suspend fun saveTimeToDb() { + when (timerState.value.timerMode) { + TimerMode.FOCUS -> statRepository.addFocusTime( + (timerState.value.totalTime - time).coerceAtLeast( + 0L + ) + ) + + else -> statRepository.addBreakTime((timerState.value.totalTime - time).coerceAtLeast(0L)) + } + } + + private fun startForegroundService() { + startForeground(1, notificationBuilder.build()) + } + + private fun stopForegroundService() { + notificationManager.cancel(1) + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + + private fun setStopButton() { + // TODO + } + + private fun setResumeButton() { + // TODO + } + + override fun onDestroy() { + super.onDestroy() + timerJob.cancel() + } + + enum class Actions { + TOGGLE, SKIP, RESET, STOP_ALARM + } +} \ No newline at end of file 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 dadfe37..24914b1 100644 --- a/app/src/main/java/org/nsh07/pomodoro/ui/AppScreen.kt +++ b/app/src/main/java/org/nsh07/pomodoro/ui/AppScreen.kt @@ -7,8 +7,10 @@ package org.nsh07.pomodoro.ui +import android.content.Intent import androidx.compose.animation.ContentTransform import androidx.compose.animation.Crossfade +import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleOut @@ -33,6 +35,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.painterResource @@ -44,19 +47,26 @@ import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.ui.NavDisplay import androidx.window.core.layout.WindowSizeClass import org.nsh07.pomodoro.MainActivity.Companion.screens +import org.nsh07.pomodoro.service.TimerService import org.nsh07.pomodoro.ui.settingsScreen.SettingsScreenRoot import org.nsh07.pomodoro.ui.statsScreen.StatsScreenRoot import org.nsh07.pomodoro.ui.statsScreen.viewModel.StatsViewModel import org.nsh07.pomodoro.ui.timerScreen.TimerScreen +import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerAction import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerViewModel -@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@OptIn( + ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class, + ExperimentalAnimationApi::class +) @Composable fun AppScreen( modifier: Modifier = Modifier, timerViewModel: TimerViewModel = viewModel(factory = TimerViewModel.Factory), statsViewModel: StatsViewModel = viewModel(factory = StatsViewModel.Factory) ) { + val context = LocalContext.current + val uiState by timerViewModel.timerState.collectAsStateWithLifecycle() val remainingTime by timerViewModel.time.collectAsStateWithLifecycle() @@ -139,7 +149,33 @@ fun AppScreen( TimerScreen( timerState = uiState, progress = { progress }, - onAction = timerViewModel::onAction, + 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), 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 ebfd45e..a173dd4 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 @@ -7,38 +7,22 @@ package org.nsh07.pomodoro.ui.timerScreen.viewModel -import android.annotation.SuppressLint import android.app.Application -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.content.Intent -import android.media.MediaPlayer -import android.os.SystemClock -import android.provider.Settings import androidx.compose.material3.ColorScheme -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.toArgb -import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY -import androidx.lifecycle.application import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import org.nsh07.pomodoro.MainActivity -import org.nsh07.pomodoro.R import org.nsh07.pomodoro.TomatoApplication import org.nsh07.pomodoro.data.PreferenceRepository import org.nsh07.pomodoro.data.Stat @@ -46,7 +30,6 @@ import org.nsh07.pomodoro.data.StatRepository import org.nsh07.pomodoro.data.TimerRepository import org.nsh07.pomodoro.utils.millisecondsToStr import java.time.LocalDate -import kotlin.text.Typography.middleDot @OptIn(FlowPreview::class) class TimerViewModel( @@ -54,20 +37,10 @@ class TimerViewModel( private val preferenceRepository: PreferenceRepository, private val statRepository: StatRepository, private val timerRepository: TimerRepository, - private val notificationBuilder: NotificationCompat.Builder, - private val notificationManager: NotificationManagerCompat + private val _timerState: MutableStateFlow, + private val _time: MutableStateFlow ) : AndroidViewModel(application) { - private val _timerState = MutableStateFlow( - TimerState( - totalTime = timerRepository.focusTime, - timeStr = millisecondsToStr(timerRepository.focusTime), - nextTimeStr = millisecondsToStr(timerRepository.shortBreakTime) - ) - ) - val timerState: StateFlow = _timerState.asStateFlow() - var timerJob: Job? = null - private val _time = MutableStateFlow(timerRepository.focusTime) val time: StateFlow = _time.asStateFlow() private var cycles = 0 @@ -78,11 +51,6 @@ class TimerViewModel( private lateinit var cs: ColorScheme - private val alarm = MediaPlayer.create( - this.application, - Settings.System.DEFAULT_ALARM_ALERT_URI ?: Settings.System.DEFAULT_RINGTONE_URI - ) - init { viewModelScope.launch(Dispatchers.IO) { timerRepository.focusTime = @@ -132,16 +100,6 @@ class TimerViewModel( cs = colorScheme } - fun onAction(action: TimerAction) { - when (action) { - is TimerAction.SkipTimer -> skipTimer(action.fromButton) - - TimerAction.ResetTimer -> resetTimer() - TimerAction.StopAlarm -> stopAlarm() - TimerAction.ToggleTimer -> toggleTimer() - } - } - private fun resetTimer() { viewModelScope.launch { saveTimeToDb() @@ -165,107 +123,6 @@ class TimerViewModel( } } - private fun skipTimer(fromButton: Boolean = false) { - viewModelScope.launch { - saveTimeToDb() - showTimerNotification(0, paused = true, complete = !fromButton) - startTime = 0L - pauseTime = 0L - pauseDuration = 0L - - cycles = (cycles + 1) % (timerRepository.sessionLength * 2) - - if (cycles % 2 == 0) { - _time.update { timerRepository.focusTime } - _timerState.update { currentState -> - currentState.copy( - timerMode = TimerMode.FOCUS, - timeStr = millisecondsToStr(time.value), - totalTime = time.value, - nextTimerMode = if (cycles == (timerRepository.sessionLength - 1) * 2) TimerMode.LONG_BREAK else TimerMode.SHORT_BREAK, - nextTimeStr = if (cycles == (timerRepository.sessionLength - 1) * 2) millisecondsToStr( - timerRepository.longBreakTime - ) else millisecondsToStr( - timerRepository.shortBreakTime - ), - currentFocusCount = cycles / 2 + 1, - totalFocusCount = timerRepository.sessionLength - ) - } - } else { - val long = cycles == (timerRepository.sessionLength * 2) - 1 - _time.update { if (long) timerRepository.longBreakTime else timerRepository.shortBreakTime } - - _timerState.update { currentState -> - currentState.copy( - timerMode = if (long) TimerMode.LONG_BREAK else TimerMode.SHORT_BREAK, - timeStr = millisecondsToStr(time.value), - totalTime = time.value, - nextTimerMode = TimerMode.FOCUS, - nextTimeStr = millisecondsToStr(timerRepository.focusTime) - ) - } - } - } - } - - private fun toggleTimer() { - if (timerState.value.timerRunning) { - showTimerNotification(time.value.toInt(), paused = true) - _timerState.update { currentState -> - currentState.copy(timerRunning = false) - } - timerJob?.cancel() - pauseTime = SystemClock.elapsedRealtime() - } else { - _timerState.update { it.copy(timerRunning = true) } - if (pauseTime != 0L) pauseDuration += SystemClock.elapsedRealtime() - pauseTime - - var iterations = -1 - - timerJob = viewModelScope.launch { - while (true) { - if (!timerState.value.timerRunning) break - if (startTime == 0L) startTime = SystemClock.elapsedRealtime() - - _time.update { - when (timerState.value.timerMode) { - TimerMode.FOCUS -> - timerRepository.focusTime - (SystemClock.elapsedRealtime() - startTime - pauseDuration).toInt() - - TimerMode.SHORT_BREAK -> - timerRepository.shortBreakTime - (SystemClock.elapsedRealtime() - startTime - pauseDuration).toInt() - - else -> - timerRepository.longBreakTime - (SystemClock.elapsedRealtime() - startTime - pauseDuration).toInt() - } - } - - iterations = (iterations + 1) % 50 - - if (iterations == 0) showTimerNotification(time.value.toInt()) - - if (time.value < 0) { - skipTimer() - - _timerState.update { currentState -> - currentState.copy(timerRunning = false) - } - timerJob?.cancel() - } else { - _timerState.update { currentState -> - currentState.copy( - timeStr = millisecondsToStr(time.value) - ) - } - } - - delay(100) - } - } - } - } - suspend fun saveTimeToDb() { when (timerState.value.timerMode) { TimerMode.FOCUS -> statRepository @@ -276,93 +133,6 @@ class TimerViewModel( } } - @SuppressLint("MissingPermission") // We check for the permission when pressing the Play button in the UI - fun showTimerNotification( - remainingTime: Int, - paused: Boolean = false, - complete: Boolean = false - ) { - val totalTime = when (timerState.value.timerMode) { - TimerMode.FOCUS -> timerRepository.focusTime.toInt() - TimerMode.SHORT_BREAK -> timerRepository.shortBreakTime.toInt() - else -> timerRepository.longBreakTime.toInt() - } - - val currentTimer = when (timerState.value.timerMode) { - TimerMode.FOCUS -> "Focus" - TimerMode.SHORT_BREAK -> "Short break" - else -> "Long break" - } - - val nextTimer = when (timerState.value.nextTimerMode) { - TimerMode.FOCUS -> "Focus" - TimerMode.SHORT_BREAK -> "Short break" - else -> "Long break" - } - - val remainingTimeString = - if ((remainingTime.toFloat() / 60000f) < 1.0f) "< 1" - else (remainingTime.toFloat() / 60000f).toInt() - - notificationManager.notify( - 1, - notificationBuilder - .setContentTitle( - if (!complete) { - "$currentTimer $middleDot $remainingTimeString min remaining" + if (paused) " $middleDot Paused" else "" - } else "$currentTimer $middleDot Completed" - ) - .setContentText("Up next: $nextTimer (${timerState.value.nextTimeStr})") - .setStyle( - NotificationCompat.ProgressStyle() - .also { - // Add all the Focus, Short break and long break intervals in order - for (i in 0.. - currentState.copy(alarmRinging = true) - } - } - } - - fun stopAlarm() { - alarm.pause() - alarm.seekTo(0) - _timerState.update { currentState -> - currentState.copy(alarmRinging = false) - } - } - companion object { val Factory: ViewModelProvider.Factory = viewModelFactory { initializer { @@ -370,43 +140,16 @@ class TimerViewModel( val appPreferenceRepository = application.container.appPreferenceRepository val appStatRepository = application.container.appStatRepository val appTimerRepository = application.container.appTimerRepository - - val notificationManager = NotificationManagerCompat.from(application) - - val notificationChannel = NotificationChannel( - "timer", - "Timer progress", - NotificationManager.IMPORTANCE_HIGH - ) - - notificationManager.createNotificationChannel(notificationChannel) - - val openAppIntent = Intent(application, MainActivity::class.java) - openAppIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - openAppIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) - - val contentIntent = PendingIntent.getActivity( - application, - System.currentTimeMillis().toInt(), - openAppIntent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - - val notificationBuilder = NotificationCompat.Builder(application, "timer") - .setSmallIcon(R.drawable.tomato_logo_notification) - .setOngoing(true) - .setColor(Color.Red.toArgb()) - .setContentIntent(contentIntent) - .setRequestPromotedOngoing(true) - .setOngoing(true) + val timerState = application.container.timerState + val time = application.container.time TimerViewModel( application = application, preferenceRepository = appPreferenceRepository, statRepository = appStatRepository, timerRepository = appTimerRepository, - notificationBuilder = notificationBuilder, - notificationManager = notificationManager + _timerState = timerState, + _time = time ) } } From fa984216f37976d179db38f3f8ead200c062de2b Mon Sep 17 00:00:00 2001 From: Nishant Mishra Date: Sun, 14 Sep 2025 12:52:21 +0530 Subject: [PATCH 09/17] fix: Save time to database when foreground service is closed Potentially fixes #10, I still need to test it though --- .../main/java/org/nsh07/pomodoro/data/AppContainer.kt | 5 +++-- .../main/java/org/nsh07/pomodoro/service/TimerService.kt | 9 ++++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/org/nsh07/pomodoro/data/AppContainer.kt b/app/src/main/java/org/nsh07/pomodoro/data/AppContainer.kt index 51c3570..7056373 100644 --- a/app/src/main/java/org/nsh07/pomodoro/data/AppContainer.kt +++ b/app/src/main/java/org/nsh07/pomodoro/data/AppContainer.kt @@ -46,10 +46,11 @@ class DefaultAppContainer(context: Context) : AppContainer { override val notificationBuilder: NotificationCompat.Builder by lazy { NotificationCompat.Builder(context, "timer") .setSmallIcon(R.drawable.tomato_logo_notification) - .setOngoing(true) .setColor(Color.Red.toArgb()) - .setRequestPromotedOngoing(true) + .setShowWhen(true) + .setSilent(true) .setOngoing(true) + .setRequestPromotedOngoing(true) } override val timerState: MutableStateFlow by lazy { diff --git a/app/src/main/java/org/nsh07/pomodoro/service/TimerService.kt b/app/src/main/java/org/nsh07/pomodoro/service/TimerService.kt index 9e94929..976be41 100644 --- a/app/src/main/java/org/nsh07/pomodoro/service/TimerService.kt +++ b/app/src/main/java/org/nsh07/pomodoro/service/TimerService.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import org.nsh07.pomodoro.TomatoApplication import org.nsh07.pomodoro.data.AppContainer import org.nsh07.pomodoro.data.StatRepository @@ -216,9 +217,7 @@ class TimerService : Service() { (totalTime - remainingTime) + ((cycles + 1) / 2) * timerRepository.focusTime.toInt() + (cycles / 2) * timerRepository.shortBreakTime.toInt() ) ) - .setShowWhen(true) .setWhen(System.currentTimeMillis() + remainingTime) // Sets the Live Activity/Now Bar chip time - .setSilent(true) .build() ) @@ -337,7 +336,11 @@ class TimerService : Service() { override fun onDestroy() { super.onDestroy() - timerJob.cancel() + runBlocking { + timerJob.cancel() + saveTimeToDb() + notificationManager.cancel(1) + } } enum class Actions { From 672571764d8a70e98d0b464dd0464a4b71817896 Mon Sep 17 00:00:00 2001 From: Nishant Mishra Date: Sun, 14 Sep 2025 13:00:36 +0530 Subject: [PATCH 10/17] fix: Make app open when clicking notification --- .../main/java/org/nsh07/pomodoro/data/AppContainer.kt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/src/main/java/org/nsh07/pomodoro/data/AppContainer.kt b/app/src/main/java/org/nsh07/pomodoro/data/AppContainer.kt index 7056373..a90f7d5 100644 --- a/app/src/main/java/org/nsh07/pomodoro/data/AppContainer.kt +++ b/app/src/main/java/org/nsh07/pomodoro/data/AppContainer.kt @@ -7,6 +7,7 @@ package org.nsh07.pomodoro.data +import android.app.PendingIntent import android.content.Context import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb @@ -47,6 +48,14 @@ class DefaultAppContainer(context: Context) : AppContainer { NotificationCompat.Builder(context, "timer") .setSmallIcon(R.drawable.tomato_logo_notification) .setColor(Color.Red.toArgb()) + .setContentIntent( + PendingIntent.getActivity( + context, + 0, + context.packageManager.getLaunchIntentForPackage(context.packageName), + PendingIntent.FLAG_IMMUTABLE + ) + ) .setShowWhen(true) .setSilent(true) .setOngoing(true) From 781c103f592fbcdf93f82d802fc79f6fa9044ba3 Mon Sep 17 00:00:00 2001 From: Nishant Mishra Date: Sun, 14 Sep 2025 15:31:27 +0530 Subject: [PATCH 11/17] feat: Add notification icons to play, pause reset and skip the timer --- .../org/nsh07/pomodoro/data/AppContainer.kt | 2 + .../org/nsh07/pomodoro/service/AddActions.kt | 62 +++++++++++++++++++ .../nsh07/pomodoro/service/TimerService.kt | 18 +++--- .../java/org/nsh07/pomodoro/ui/AppScreen.kt | 6 +- 4 files changed, 73 insertions(+), 15 deletions(-) create mode 100644 app/src/main/java/org/nsh07/pomodoro/service/AddActions.kt diff --git a/app/src/main/java/org/nsh07/pomodoro/data/AppContainer.kt b/app/src/main/java/org/nsh07/pomodoro/data/AppContainer.kt index a90f7d5..2bde857 100644 --- a/app/src/main/java/org/nsh07/pomodoro/data/AppContainer.kt +++ b/app/src/main/java/org/nsh07/pomodoro/data/AppContainer.kt @@ -15,6 +15,7 @@ import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import kotlinx.coroutines.flow.MutableStateFlow import org.nsh07.pomodoro.R +import org.nsh07.pomodoro.service.addTimerActions import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerState import org.nsh07.pomodoro.utils.millisecondsToStr @@ -56,6 +57,7 @@ class DefaultAppContainer(context: Context) : AppContainer { PendingIntent.FLAG_IMMUTABLE ) ) + .addTimerActions(context, R.drawable.play, "Start") .setShowWhen(true) .setSilent(true) .setOngoing(true) diff --git a/app/src/main/java/org/nsh07/pomodoro/service/AddActions.kt b/app/src/main/java/org/nsh07/pomodoro/service/AddActions.kt new file mode 100644 index 0000000..5475c1e --- /dev/null +++ b/app/src/main/java/org/nsh07/pomodoro/service/AddActions.kt @@ -0,0 +1,62 @@ +/* + * 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.service + +import android.app.PendingIntent +import android.app.PendingIntent.FLAG_IMMUTABLE +import android.content.Context +import android.content.Intent +import androidx.annotation.DrawableRes +import androidx.core.app.NotificationCompat +import org.nsh07.pomodoro.R + +fun NotificationCompat.Builder.addTimerActions( + context: Context, + @DrawableRes playPauseIcon: Int, + playPauseText: String +): NotificationCompat.Builder { + this + .addAction( + playPauseIcon, + playPauseText, + PendingIntent.getService( + context, + 0, + Intent(context, TimerService::class.java).also { + it.action = TimerService.Actions.TOGGLE.toString() + }, + FLAG_IMMUTABLE + ) + ) + .addAction( + R.drawable.restart, + "Reset", + PendingIntent.getService( + context, + 0, + Intent(context, TimerService::class.java).also { + it.action = TimerService.Actions.RESET.toString() + }, + FLAG_IMMUTABLE + ) + ) + .addAction( + R.drawable.skip_next, + "Skip", + PendingIntent.getService( + context, + 0, + Intent(context, TimerService::class.java).also { + it.action = TimerService.Actions.SKIP.toString() + }, + FLAG_IMMUTABLE + ) + ) + + return this +} \ No newline at end of file diff --git a/app/src/main/java/org/nsh07/pomodoro/service/TimerService.kt b/app/src/main/java/org/nsh07/pomodoro/service/TimerService.kt index 976be41..8b32a94 100644 --- a/app/src/main/java/org/nsh07/pomodoro/service/TimerService.kt +++ b/app/src/main/java/org/nsh07/pomodoro/service/TimerService.kt @@ -7,7 +7,6 @@ import android.media.MediaPlayer import android.os.IBinder import android.os.SystemClock import android.provider.Settings -import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.material3.ColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.ui.graphics.toArgb @@ -23,6 +22,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import org.nsh07.pomodoro.R import org.nsh07.pomodoro.TomatoApplication import org.nsh07.pomodoro.data.AppContainer import org.nsh07.pomodoro.data.StatRepository @@ -32,7 +32,6 @@ import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerState import org.nsh07.pomodoro.utils.millisecondsToStr import kotlin.text.Typography.middleDot -@ExperimentalAnimationApi class TimerService : Service() { private lateinit var appContainer: AppContainer @@ -108,6 +107,13 @@ class TimerService : Service() { } private fun toggleTimer() { + notificationBuilder + .clearActions() + .addTimerActions( + this, + if (timerState.value.timerRunning) R.drawable.pause else R.drawable.play, + if (timerState.value.timerRunning) "Stop" else "Start" + ) if (timerState.value.timerRunning) { showTimerNotification(time.toInt(), paused = true) _timerState.update { currentState -> @@ -326,14 +332,6 @@ class TimerService : Service() { stopSelf() } - private fun setStopButton() { - // TODO - } - - private fun setResumeButton() { - // TODO - } - override fun onDestroy() { super.onDestroy() runBlocking { 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 24914b1..b2437f6 100644 --- a/app/src/main/java/org/nsh07/pomodoro/ui/AppScreen.kt +++ b/app/src/main/java/org/nsh07/pomodoro/ui/AppScreen.kt @@ -10,7 +10,6 @@ package org.nsh07.pomodoro.ui import android.content.Intent import androidx.compose.animation.ContentTransform import androidx.compose.animation.Crossfade -import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleOut @@ -55,10 +54,7 @@ import org.nsh07.pomodoro.ui.timerScreen.TimerScreen import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerAction import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerViewModel -@OptIn( - ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class, - ExperimentalAnimationApi::class -) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun AppScreen( modifier: Modifier = Modifier, From f28fc8a2c1852d91975440d68aff3653a9967beb Mon Sep 17 00:00:00 2001 From: Nishant Mishra Date: Sun, 14 Sep 2025 15:36:18 +0530 Subject: [PATCH 12/17] feat: Show exact time remaining in Live Update/Now Bar --- app/src/main/java/org/nsh07/pomodoro/service/TimerService.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/nsh07/pomodoro/service/TimerService.kt b/app/src/main/java/org/nsh07/pomodoro/service/TimerService.kt index 8b32a94..b906677 100644 --- a/app/src/main/java/org/nsh07/pomodoro/service/TimerService.kt +++ b/app/src/main/java/org/nsh07/pomodoro/service/TimerService.kt @@ -139,7 +139,7 @@ class TimerService : Service() { else -> timerRepository.longBreakTime - (SystemClock.elapsedRealtime() - startTime - pauseDuration).toInt() } - iterations = (iterations + 1) % 50 + iterations = (iterations + 1) % 10 if (iterations == 0) showTimerNotification(time.toInt()) @@ -224,6 +224,7 @@ class TimerService : Service() { ) ) .setWhen(System.currentTimeMillis() + remainingTime) // Sets the Live Activity/Now Bar chip time + .setShortCriticalText(millisecondsToStr(time)) .build() ) From c1c2f57ddc86593dd288c3c07b1085da136de1e9 Mon Sep 17 00:00:00 2001 From: Nishant Mishra Date: Sun, 14 Sep 2025 16:48:25 +0530 Subject: [PATCH 13/17] fix: Next timer not starting when current timer was completed --- .../org/nsh07/pomodoro/service/AddActions.kt | 86 +++++++++++-------- .../nsh07/pomodoro/service/TimerService.kt | 25 ++++-- .../pomodoro/ui/timerScreen/TimerScreen.kt | 6 +- 3 files changed, 70 insertions(+), 47 deletions(-) diff --git a/app/src/main/java/org/nsh07/pomodoro/service/AddActions.kt b/app/src/main/java/org/nsh07/pomodoro/service/AddActions.kt index 5475c1e..181e4f9 100644 --- a/app/src/main/java/org/nsh07/pomodoro/service/AddActions.kt +++ b/app/src/main/java/org/nsh07/pomodoro/service/AddActions.kt @@ -19,44 +19,56 @@ fun NotificationCompat.Builder.addTimerActions( context: Context, @DrawableRes playPauseIcon: Int, playPauseText: String -): NotificationCompat.Builder { - this - .addAction( - playPauseIcon, - playPauseText, - PendingIntent.getService( - context, - 0, - Intent(context, TimerService::class.java).also { - it.action = TimerService.Actions.TOGGLE.toString() - }, - FLAG_IMMUTABLE - ) +): NotificationCompat.Builder = this + .addAction( + playPauseIcon, + playPauseText, + PendingIntent.getService( + context, + 0, + Intent(context, TimerService::class.java).also { + it.action = TimerService.Actions.TOGGLE.toString() + }, + FLAG_IMMUTABLE ) - .addAction( - R.drawable.restart, - "Reset", - PendingIntent.getService( - context, - 0, - Intent(context, TimerService::class.java).also { - it.action = TimerService.Actions.RESET.toString() - }, - FLAG_IMMUTABLE - ) + ) + .addAction( + R.drawable.restart, + "Reset", + PendingIntent.getService( + context, + 0, + Intent(context, TimerService::class.java).also { + it.action = TimerService.Actions.RESET.toString() + }, + FLAG_IMMUTABLE ) - .addAction( - R.drawable.skip_next, - "Skip", - PendingIntent.getService( - context, - 0, - Intent(context, TimerService::class.java).also { - it.action = TimerService.Actions.SKIP.toString() - }, - FLAG_IMMUTABLE - ) + ) + .addAction( + R.drawable.skip_next, + "Skip", + PendingIntent.getService( + context, + 0, + Intent(context, TimerService::class.java).also { + it.action = TimerService.Actions.SKIP.toString() + }, + FLAG_IMMUTABLE ) + ) - return this -} \ No newline at end of file +fun NotificationCompat.Builder.addStopAlarmAction( + context: Context +): NotificationCompat.Builder = this + .addAction( + R.drawable.alarm, + "Stop alarm", + PendingIntent.getService( + context, + 0, + Intent(context, TimerService::class.java).also { + it.action = TimerService.Actions.STOP_ALARM.toString() + }, + FLAG_IMMUTABLE + ) + ) \ No newline at end of file diff --git a/app/src/main/java/org/nsh07/pomodoro/service/TimerService.kt b/app/src/main/java/org/nsh07/pomodoro/service/TimerService.kt index b906677..291d2b6 100644 --- a/app/src/main/java/org/nsh07/pomodoro/service/TimerService.kt +++ b/app/src/main/java/org/nsh07/pomodoro/service/TimerService.kt @@ -57,9 +57,9 @@ class TimerService : Service() { private var pauseTime = 0L private var pauseDuration = 0L - private val timerJob = SupervisorJob() - private val scope = CoroutineScope(Dispatchers.IO + timerJob) - private val skipScope = CoroutineScope(Dispatchers.IO) + private var job = SupervisorJob() + private val scope = CoroutineScope(Dispatchers.IO + job) + private val skipScope = CoroutineScope(Dispatchers.IO + job) private lateinit var alarm: MediaPlayer @@ -145,11 +145,10 @@ class TimerService : Service() { if (time < 0) { skipTimer() - _timerState.update { currentState -> currentState.copy(timerRunning = false) } - timerJob.cancel() + break } else { _timerState.update { currentState -> currentState.copy( @@ -168,6 +167,8 @@ class TimerService : Service() { fun showTimerNotification( remainingTime: Int, paused: Boolean = false, complete: Boolean = false ) { + if (complete) notificationBuilder.clearActions().addStopAlarmAction(this) + val totalTime = when (timerState.value.timerMode) { TimerMode.FOCUS -> timerRepository.focusTime.toInt() TimerMode.SHORT_BREAK -> timerRepository.shortBreakTime.toInt() @@ -224,7 +225,7 @@ class TimerService : Service() { ) ) .setWhen(System.currentTimeMillis() + remainingTime) // Sets the Live Activity/Now Bar chip time - .setShortCriticalText(millisecondsToStr(time)) + .setShortCriticalText(millisecondsToStr(time.coerceAtLeast(0))) .build() ) @@ -309,6 +310,16 @@ class TimerService : Service() { _timerState.update { currentState -> currentState.copy(alarmRinging = false) } + notificationBuilder.clearActions().addTimerActions(this, R.drawable.play, "Start") + showTimerNotification( + when (timerState.value.timerMode) { + TimerMode.FOCUS -> timerRepository.focusTime.toInt() + TimerMode.SHORT_BREAK -> timerRepository.shortBreakTime.toInt() + else -> timerRepository.longBreakTime.toInt() + }, + paused = true, + complete = false + ) } suspend fun saveTimeToDb() { @@ -336,7 +347,7 @@ class TimerService : Service() { override fun onDestroy() { super.onDestroy() runBlocking { - timerJob.cancel() + job.cancel() saveTimeToDb() notificationManager.cancel(1) } 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 dae0e4e..ba3a1b2 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 @@ -205,7 +205,7 @@ fun TimerScreen( color = color, trackColor = colorContainer, strokeWidth = 16.dp, - gapSize = 16.dp + gapSize = 8.dp ) } else { CircularWavyProgressIndicator( @@ -229,7 +229,7 @@ fun TimerScreen( cap = StrokeCap.Round, ), wavelength = 60.dp, - gapSize = 16.dp + gapSize = 8.dp ) } var expanded by remember { mutableStateOf(timerState.showBrandTitle) } @@ -300,10 +300,10 @@ fun TimerScreen( { FilledIconToggleButton( onCheckedChange = { checked -> + onAction(TimerAction.ToggleTimer) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && checked) { permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) } - onAction(TimerAction.ToggleTimer) }, checked = timerState.timerRunning, colors = IconButtonDefaults.filledIconToggleButtonColors( From fdca3cfa2aab5d926cf858d937a9623b41a2b632 Mon Sep 17 00:00:00 2001 From: Nishant Mishra Date: Sun, 14 Sep 2025 17:07:32 +0530 Subject: [PATCH 14/17] fix: Show only the progress for the current timer in notification when running on Android 15 or lower --- .../nsh07/pomodoro/service/TimerService.kt | 70 +++++++++++++------ 1 file changed, 47 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/org/nsh07/pomodoro/service/TimerService.kt b/app/src/main/java/org/nsh07/pomodoro/service/TimerService.kt index 291d2b6..82c2ff4 100644 --- a/app/src/main/java/org/nsh07/pomodoro/service/TimerService.kt +++ b/app/src/main/java/org/nsh07/pomodoro/service/TimerService.kt @@ -4,6 +4,7 @@ import android.annotation.SuppressLint import android.app.Service import android.content.Intent import android.media.MediaPlayer +import android.os.Build import android.os.IBinder import android.os.SystemClock import android.provider.Settings @@ -107,20 +108,27 @@ class TimerService : Service() { } private fun toggleTimer() { - notificationBuilder - .clearActions() - .addTimerActions( - this, - if (timerState.value.timerRunning) R.drawable.pause else R.drawable.play, - if (timerState.value.timerRunning) "Stop" else "Start" - ) if (timerState.value.timerRunning) { + notificationBuilder + .clearActions() + .addTimerActions( + this, + R.drawable.play, + "Start" + ) showTimerNotification(time.toInt(), paused = true) _timerState.update { currentState -> currentState.copy(timerRunning = false) } pauseTime = SystemClock.elapsedRealtime() } else { + notificationBuilder + .clearActions() + .addTimerActions( + this, + R.drawable.pause, + "Stop" + ) _timerState.update { it.copy(timerRunning = true) } if (pauseTime != 0L) pauseDuration += SystemClock.elapsedRealtime() - pauseTime @@ -202,26 +210,42 @@ class TimerService : Service() { .setStyle( NotificationCompat.ProgressStyle().also { // Add all the Focus, Short break and long break intervals in order - for (i in 0..= Build.VERSION_CODES.BAKLAVA) { + // Android 16 and later supports live updates + // Set progress bar sections if on Baklava or later + for (i in 0.. timerRepository.focusTime.toInt() + TimerMode.SHORT_BREAK -> timerRepository.shortBreakTime.toInt() + else -> timerRepository.longBreakTime.toInt() + } + ) ) } } .setProgress( // Set the current progress by filling the previous intervals and part of the current interval - (totalTime - remainingTime) + ((cycles + 1) / 2) * timerRepository.focusTime.toInt() + (cycles / 2) * timerRepository.shortBreakTime.toInt() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) { + (totalTime - remainingTime) + ((cycles + 1) / 2) * timerRepository.focusTime.toInt() + (cycles / 2) * timerRepository.shortBreakTime.toInt() + } else (totalTime - remainingTime) ) ) .setWhen(System.currentTimeMillis() + remainingTime) // Sets the Live Activity/Now Bar chip time @@ -310,7 +334,7 @@ class TimerService : Service() { _timerState.update { currentState -> currentState.copy(alarmRinging = false) } - notificationBuilder.clearActions().addTimerActions(this, R.drawable.play, "Start") + notificationBuilder.clearActions().addTimerActions(this, R.drawable.play, "Start next") showTimerNotification( when (timerState.value.timerMode) { TimerMode.FOCUS -> timerRepository.focusTime.toInt() From da1cee7f54c8d35691eea03af81f74ebf0c9a81d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 21 Aug 2025 06:48:46 +0000 Subject: [PATCH 15/17] chore(deps): update actions/setup-java action to v5 --- .github/workflows/android.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 952ed4d..e21b4fc 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -9,7 +9,7 @@ jobs: steps: - uses: actions/checkout@v5 - name: Set up JDK 21 - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: java-version: '21' distribution: 'temurin' From 7f25057bf9d898f7d0469ed4b7af66c8ac8c20e6 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 13 Sep 2025 01:08:50 +0000 Subject: [PATCH 16/17] fix(deps): update all non-major dependencies --- gradle/libs.versions.toml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index df0387b..60641b6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,17 +1,17 @@ [versions] -activityCompose = "1.10.1" +activityCompose = "1.11.0" adaptive = "1.1.0" -agp = "8.11.1" -composeBom = "2025.08.00" +agp = "8.13.0" +composeBom = "2025.09.00" coreKtx = "1.17.0" espressoCore = "3.7.0" junit = "4.13.2" junitVersion = "1.3.0" -kotlin = "2.2.10" -ksp = "2.2.10-2.0.2" -lifecycleRuntimeKtx = "2.9.2" -navigation3Runtime = "1.0.0-alpha07" -room = "2.7.2" +kotlin = "2.2.20" +ksp = "2.2.20-2.0.3" +lifecycleRuntimeKtx = "2.9.3" +navigation3Runtime = "1.0.0-alpha09" +room = "2.8.0" vico = "2.2.0-alpha.1" [libraries] From 567fa440e3773e94ab969e1f271aaf025ce2348e Mon Sep 17 00:00:00 2001 From: Nishant Mishra Date: Sun, 14 Sep 2025 19:02:01 +0530 Subject: [PATCH 17/17] chore: Bump version string, update changelog --- app/build.gradle.kts | 4 ++-- fastlane/metadata/android/en-US/changelogs/6.txt | 9 +++++++++ gradle/libs.versions.toml | 2 +- 3 files changed, 12 insertions(+), 3 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/6.txt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d28b7bd..94d84d2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -33,8 +33,8 @@ android { applicationId = "org.nsh07.pomodoro" minSdk = 26 targetSdk = 36 - versionCode = 5 - versionName = "1.1.0" + versionCode = 6 + versionName = "1.2.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/fastlane/metadata/android/en-US/changelogs/6.txt b/fastlane/metadata/android/en-US/changelogs/6.txt new file mode 100644 index 0000000..1eb6828 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/6.txt @@ -0,0 +1,9 @@ +New features: +- The app now rings the system alarm sound when a timer completes +- Clicking the timer clock now shows the current session count +- The app can now stay in the background and the timer keeps running even when the app is closed +- New notification buttons to control the timer without opening the app + +Fixes: +- Current elapsed time is now saved in the stats when the app is closed while a timer is running +- Live Updates/Now Bar now shows precisely how much time is remaining (in seconds) \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 60641b6..3725628 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] activityCompose = "1.11.0" adaptive = "1.1.0" -agp = "8.13.0" +agp = "8.11.1" composeBom = "2025.09.00" coreKtx = "1.17.0" espressoCore = "3.7.0"