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'
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 090bdcb..94d84d2 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -33,15 +33,16 @@ 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"
}
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
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..2bde857 100644
--- a/app/src/main/java/org/nsh07/pomodoro/data/AppContainer.kt
+++ b/app/src/main/java/org/nsh07/pomodoro/data/AppContainer.kt
@@ -7,12 +7,26 @@
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
+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
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 +41,41 @@ 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)
+ .setColor(Color.Red.toArgb())
+ .setContentIntent(
+ PendingIntent.getActivity(
+ context,
+ 0,
+ context.packageManager.getLaunchIntentForPackage(context.packageName),
+ PendingIntent.FLAG_IMMUTABLE
+ )
+ )
+ .addTimerActions(context, R.drawable.play, "Start")
+ .setShowWhen(true)
+ .setSilent(true)
+ .setOngoing(true)
+ .setRequestPromotedOngoing(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/AddActions.kt b/app/src/main/java/org/nsh07/pomodoro/service/AddActions.kt
new file mode 100644
index 0000000..181e4f9
--- /dev/null
+++ b/app/src/main/java/org/nsh07/pomodoro/service/AddActions.kt
@@ -0,0 +1,74 @@
+/*
+ * 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
+ )
+ )
+
+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
new file mode 100644
index 0000000..82c2ff4
--- /dev/null
+++ b/app/src/main/java/org/nsh07/pomodoro/service/TimerService.kt
@@ -0,0 +1,383 @@
+package org.nsh07.pomodoro.service
+
+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
+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 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
+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
+
+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 var job = SupervisorJob()
+ private val scope = CoroutineScope(Dispatchers.IO + job)
+ private val skipScope = CoroutineScope(Dispatchers.IO + job)
+
+ 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) {
+ 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
+
+ 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) % 10
+
+ if (iterations == 0) showTimerNotification(time.toInt())
+
+ if (time < 0) {
+ skipTimer()
+ _timerState.update { currentState ->
+ currentState.copy(timerRunning = false)
+ }
+ break
+ } 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
+ ) {
+ if (complete) notificationBuilder.clearActions().addStopAlarmAction(this)
+
+ 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
+ if (Build.VERSION.SDK_INT >= 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
+ 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
+ .setShortCriticalText(millisecondsToStr(time.coerceAtLeast(0)))
+ .build()
+ )
+
+ if (complete) {
+ alarm.start()
+ _timerState.update { currentState ->
+ 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)
+ }
+ notificationBuilder.clearActions().addTimerActions(this, R.drawable.play, "Start next")
+ 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() {
+ 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()
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ runBlocking {
+ job.cancel()
+ saveTimeToDb()
+ notificationManager.cancel(1)
+ }
+ }
+
+ 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 6a61814..b2437f6 100644
--- a/app/src/main/java/org/nsh07/pomodoro/ui/AppScreen.kt
+++ b/app/src/main/java/org/nsh07/pomodoro/ui/AppScreen.kt
@@ -7,6 +7,7 @@
package org.nsh07.pomodoro.ui
+import android.content.Intent
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.Crossfade
import androidx.compose.animation.fadeIn
@@ -33,6 +34,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,10 +46,12 @@ 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)
@@ -57,6 +61,8 @@ fun AppScreen(
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 +145,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),
@@ -160,6 +192,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/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/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()),
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
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 4c492c1..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
@@ -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
@@ -99,6 +111,9 @@ fun TimerScreen(
onResult = {}
)
+ if (timerState.alarmRinging)
+ AlarmDialog { onAction(TimerAction.StopAlarm) }
+
Column(modifier = modifier) {
TopAppBar(
title = {
@@ -170,15 +185,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(
@@ -190,7 +205,7 @@ fun TimerScreen(
color = color,
trackColor = colorContainer,
strokeWidth = 16.dp,
- gapSize = 16.dp
+ gapSize = 8.dp
)
} else {
CircularWavyProgressIndicator(
@@ -214,20 +229,45 @@ fun TimerScreen(
cap = StrokeCap.Round,
),
wavelength = 60.dp,
- gapSize = 16.dp
+ gapSize = 8.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(
+ "${timerState.currentFocusCount} of ${timerState.totalFocusCount}",
+ fontFamily = openRundeClock,
+ style = typography.titleLarge,
+ color = colorScheme.outline
+ )
+ }
+ }
}
val interactionSources = remember { List(3) { MutableInteractionSource() } }
ButtonGroup(
@@ -260,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(
@@ -355,7 +395,7 @@ fun TimerScreen(
customItem(
{
FilledTonalIconButton(
- onClick = { onAction(TimerAction.SkipTimer) },
+ onClick = { onAction(TimerAction.SkipTimer(fromButton = true)) },
colors = IconButtonDefaults.filledTonalIconButtonColors(
containerColor = colorContainer
),
@@ -382,7 +422,7 @@ fun TimerScreen(
},
text = { Text("Skip to next") },
onClick = {
- onAction(TimerAction.SkipTimer)
+ onAction(TimerAction.SkipTimer(fromButton = true))
state.dismiss()
}
)
@@ -393,7 +433,7 @@ fun TimerScreen(
Spacer(Modifier.height(32.dp))
- Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Column(horizontalAlignment = CenterHorizontally) {
Text("Up next", style = typography.titleSmall)
Text(
timerState.nextTimeStr,
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..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,7 +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/TimerState.kt b/app/src/main/java/org/nsh07/pomodoro/ui/timerScreen/viewModel/TimerState.kt
index f0ead74..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
@@ -14,7 +14,10 @@ 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,
+ 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 74cc8ac..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,18 +7,9 @@
package org.nsh07.pomodoro.ui.timerScreen.viewModel
-import android.annotation.SuppressLint
-import android.app.NotificationChannel
-import android.app.NotificationManager
-import android.app.PendingIntent
-import android.content.Intent
-import android.os.SystemClock
+import android.app.Application
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.viewModelScope
@@ -26,15 +17,12 @@ 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
@@ -42,27 +30,17 @@ 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(
+ application: Application,
private val preferenceRepository: PreferenceRepository,
private val statRepository: StatRepository,
private val timerRepository: TimerRepository,
- private val notificationBuilder: NotificationCompat.Builder,
- private val notificationManager: NotificationManagerCompat
-) : ViewModel() {
- private val _timerState = MutableStateFlow(
- TimerState(
- totalTime = timerRepository.focusTime,
- timeStr = millisecondsToStr(timerRepository.focusTime),
- nextTimeStr = millisecondsToStr(timerRepository.shortBreakTime)
- )
- )
-
+ private val _timerState: MutableStateFlow,
+ private val _time: MutableStateFlow
+) : AndroidViewModel(application) {
val timerState: StateFlow = _timerState.asStateFlow()
- var timerJob: Job? = null
- private val _time = MutableStateFlow(timerRepository.focusTime)
val time: StateFlow = _time.asStateFlow()
private var cycles = 0
@@ -122,14 +100,6 @@ class TimerViewModel(
cs = colorScheme
}
- fun onAction(action: TimerAction) {
- when (action) {
- TimerAction.ResetTimer -> resetTimer()
- TimerAction.SkipTimer -> skipTimer()
- TimerAction.ToggleTimer -> toggleTimer()
- }
- }
-
private fun resetTimer() {
viewModelScope.launch {
saveTimeToDb()
@@ -145,111 +115,14 @@ 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
)
}
}
}
- private fun skipTimer() {
- viewModelScope.launch {
- saveTimeToDb()
- showTimerNotification(0, paused = true, complete = true)
- 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
- )
- )
- }
- } 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
@@ -260,78 +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...
+ -->
+
+
+
+
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 df0387b..3725628 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"
+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]