Merge pull request #26 from nsh07/timer-notification

Timer notification
This commit is contained in:
Nishant Mishra
2025-08-23 11:01:08 +05:30
committed by GitHub
5 changed files with 179 additions and 4 deletions

View File

@@ -2,6 +2,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.POST_PROMOTED_NOTIFICATIONS" />
<application <application
android:name=".TomatoApplication" android:name=".TomatoApplication"
android:allowBackup="true" android:allowBackup="true"

View File

@@ -5,6 +5,7 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.compose.material3.MaterialTheme.colorScheme
import org.nsh07.pomodoro.ui.AppScreen import org.nsh07.pomodoro.ui.AppScreen
import org.nsh07.pomodoro.ui.NavItem import org.nsh07.pomodoro.ui.NavItem
import org.nsh07.pomodoro.ui.Screen import org.nsh07.pomodoro.ui.Screen
@@ -22,6 +23,7 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
TomatoTheme { TomatoTheme {
timerViewModel.setCompositionLocals(colorScheme)
AppScreen(timerViewModel = timerViewModel, statsViewModel = statsViewModel) AppScreen(timerViewModel = timerViewModel, statsViewModel = statsViewModel)
} }
} }

View File

@@ -7,6 +7,10 @@
package org.nsh07.pomodoro.ui.timerScreen package org.nsh07.pomodoro.ui.timerScreen
import android.Manifest
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideInVertically
@@ -90,6 +94,11 @@ fun TimerScreen(
animationSpec = motionScheme.slowEffectsSpec() animationSpec = motionScheme.slowEffectsSpec()
) )
val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
onResult = {}
)
Column(modifier = modifier) { Column(modifier = modifier) {
TopAppBar( TopAppBar(
title = { title = {
@@ -250,7 +259,12 @@ fun TimerScreen(
customItem( customItem(
{ {
FilledIconToggleButton( FilledIconToggleButton(
onCheckedChange = { onAction(TimerAction.ToggleTimer) }, onCheckedChange = { checked ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && checked) {
permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
onAction(TimerAction.ToggleTimer)
},
checked = timerState.timerRunning, checked = timerState.timerRunning,
colors = IconButtonDefaults.filledIconToggleButtonColors( colors = IconButtonDefaults.filledIconToggleButtonColors(
checkedContainerColor = color, checkedContainerColor = color,

View File

@@ -7,7 +7,17 @@
package org.nsh07.pomodoro.ui.timerScreen.viewModel 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.os.SystemClock
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.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
@@ -23,6 +33,8 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.nsh07.pomodoro.MainActivity
import org.nsh07.pomodoro.R
import org.nsh07.pomodoro.TomatoApplication import org.nsh07.pomodoro.TomatoApplication
import org.nsh07.pomodoro.data.PreferenceRepository import org.nsh07.pomodoro.data.PreferenceRepository
import org.nsh07.pomodoro.data.Stat import org.nsh07.pomodoro.data.Stat
@@ -30,14 +42,16 @@ import org.nsh07.pomodoro.data.StatRepository
import org.nsh07.pomodoro.data.TimerRepository import org.nsh07.pomodoro.data.TimerRepository
import org.nsh07.pomodoro.utils.millisecondsToStr import org.nsh07.pomodoro.utils.millisecondsToStr
import java.time.LocalDate import java.time.LocalDate
import kotlin.text.Typography.middleDot
@OptIn(FlowPreview::class) @OptIn(FlowPreview::class)
class TimerViewModel( class TimerViewModel(
private val preferenceRepository: PreferenceRepository, private val preferenceRepository: PreferenceRepository,
private val statRepository: StatRepository, private val statRepository: StatRepository,
private val timerRepository: TimerRepository private val timerRepository: TimerRepository,
private val notificationBuilder: NotificationCompat.Builder,
private val notificationManager: NotificationManagerCompat
) : ViewModel() { ) : ViewModel() {
// TODO: Document code
private val _timerState = MutableStateFlow( private val _timerState = MutableStateFlow(
TimerState( TimerState(
totalTime = timerRepository.focusTime, totalTime = timerRepository.focusTime,
@@ -57,6 +71,8 @@ class TimerViewModel(
private var pauseTime = 0L private var pauseTime = 0L
private var pauseDuration = 0L private var pauseDuration = 0L
private lateinit var cs: ColorScheme
init { init {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
timerRepository.focusTime = timerRepository.focusTime =
@@ -102,6 +118,10 @@ class TimerViewModel(
} }
} }
fun setCompositionLocals(colorScheme: ColorScheme) {
cs = colorScheme
}
fun onAction(action: TimerAction) { fun onAction(action: TimerAction) {
when (action) { when (action) {
TimerAction.ResetTimer -> resetTimer() TimerAction.ResetTimer -> resetTimer()
@@ -134,6 +154,7 @@ class TimerViewModel(
private fun skipTimer() { private fun skipTimer() {
viewModelScope.launch { viewModelScope.launch {
saveTimeToDb() saveTimeToDb()
showTimerNotification(0, paused = true, complete = true)
startTime = 0L startTime = 0L
pauseTime = 0L pauseTime = 0L
pauseDuration = 0L pauseDuration = 0L
@@ -174,6 +195,7 @@ class TimerViewModel(
private fun toggleTimer() { private fun toggleTimer() {
if (timerState.value.timerRunning) { if (timerState.value.timerRunning) {
showTimerNotification(time.value.toInt(), paused = true)
_timerState.update { currentState -> _timerState.update { currentState ->
currentState.copy(timerRunning = false) currentState.copy(timerRunning = false)
} }
@@ -183,6 +205,8 @@ class TimerViewModel(
_timerState.update { it.copy(timerRunning = true) } _timerState.update { it.copy(timerRunning = true) }
if (pauseTime != 0L) pauseDuration += SystemClock.elapsedRealtime() - pauseTime if (pauseTime != 0L) pauseDuration += SystemClock.elapsedRealtime() - pauseTime
var iterations = -1
timerJob = viewModelScope.launch { timerJob = viewModelScope.launch {
while (true) { while (true) {
if (!timerState.value.timerRunning) break if (!timerState.value.timerRunning) break
@@ -201,6 +225,10 @@ class TimerViewModel(
} }
} }
iterations = (iterations + 1) % 50
if (iterations == 0) showTimerNotification(time.value.toInt())
if (time.value < 0) { if (time.value < 0) {
skipTimer() skipTimer()
@@ -232,6 +260,78 @@ 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..<timerRepository.sessionLength * 2) {
if (i % 2 == 0) it.addProgressSegment(
NotificationCompat.ProgressStyle.Segment(
timerRepository.focusTime.toInt()
).setColor(cs.primary.toArgb())
)
else if (i != (timerRepository.sessionLength * 2 - 1)) it.addProgressSegment(
NotificationCompat.ProgressStyle.Segment(
timerRepository.shortBreakTime.toInt()
).setColor(cs.tertiary.toArgb())
)
else it.addProgressSegment(
NotificationCompat.ProgressStyle.Segment(
timerRepository.longBreakTime.toInt()
).setColor(cs.tertiary.toArgb())
)
}
}
.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()
)
)
.setShowWhen(true)
.setWhen(System.currentTimeMillis() + remainingTime) // Sets the Live Activity/Now Bar chip time
.setSilent(!complete)
.build()
)
}
companion object { companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory { val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer { initializer {
@@ -240,10 +340,41 @@ class TimerViewModel(
val appStatRepository = application.container.appStatRepository val appStatRepository = application.container.appStatRepository
val appTimerRepository = application.container.appTimerRepository 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)
TimerViewModel( TimerViewModel(
preferenceRepository = appPreferenceRepository, preferenceRepository = appPreferenceRepository,
statRepository = appStatRepository, statRepository = appStatRepository,
timerRepository = appTimerRepository timerRepository = appTimerRepository,
notificationBuilder = notificationBuilder,
notificationManager = notificationManager
) )
} }
} }

View File

@@ -0,0 +1,25 @@
<!--
~ Copyright (c) 2025 Nishant Mishra
~
~ You should have received a copy of the GNU General Public License
~ along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="96dp"
android:height="96dp"
android:viewportWidth="96"
android:viewportHeight="96">
<path
android:fillColor="#000000"
android:pathData="m43.703,21.153a2.562,2.562 0,0 1,-0.193 0.169c0.112,-0.017 0.242,-0.011 0.356,-0.027a2.562,2.562 0,0 1,-0.163 -0.142zM51.619,21.34a2.562,2.562 0,0 1,-0.145 0.096c0.084,0.014 0.176,0.019 0.259,0.033a2.562,2.562 0,0 1,-0.115 -0.13zM22.448,24.643A41.674,38.064 0,0 0,6.326 54.715,41.674 38.064,0 0,0 48,92.778 41.674,38.064 0,0 0,89.674 54.715,41.674 38.064,0 0,0 74.028,24.989c-2.769,1.727 -5.538,2.367 -8.088,3.119A2.562,2.562 0,0 1,64.384 27.988c2.579,2.775 3.779,5.491 3.779,5.491a2.562,2.562 0,0 1,-2.351 3.466c-3.918,0.074 -7.789,-0.365 -11.328,-1.986 -2.626,-1.203 -4.872,-3.337 -6.838,-5.961 -5.06,5.243 -11.259,7.985 -17.979,8.534a2.562,2.562 0,0 1,-2.61 -3.447c0,0 1.303,-3.494 4.538,-6.801 0.111,-0.113 0.273,-0.218 0.389,-0.331a2.562,2.562 0,0 1,-0.368 0.163c-3.53,-0.053 -6.551,-0.997 -9.17,-2.471z"
android:strokeWidth="0"
android:strokeColor="#00000000"
android:strokeLineJoin="round" />
<path
android:fillColor="#000000"
android:pathData="M59.132,3.222C44.223,4.02 43.871,13.569 44.908,19.578l5.365,0.129C52.013,15.381 50.024,9.674 61.717,9.04c0,-2.399 0.111,-4.904 -2.585,-5.818zM68.267,14.779C62.769,14.516 56.573,15.456 53.784,20.256l11.431,5.395c4.907,-1.447 8.898,-1.794 13.348,-9.144 0,0 -4.799,-1.465 -10.296,-1.728zM26.572,15.03c-5.292,0.217 -9.638,1.476 -9.638,1.476 3.724,4.456 8.092,7.946 14.721,8.046 2.54,-2.895 6.253,-4.035 10.243,-4.756 -3.795,-4.16 -10.035,-4.983 -15.327,-4.766zM47.886,23.546C33.896,23.043 29.462,34.978 29.462,34.978c7.162,-0.585 13.455,-3.491 18.425,-9.922 3.986,7.619 10.498,9.467 17.879,9.327 0,0 -3.889,-10.333 -17.879,-10.836z"
android:strokeWidth="0"
android:strokeColor="#00000000"
android:strokeLineJoin="round" />
</vector>