Merge pull request #26 from nsh07/timer-notification
Timer notification
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
25
app/src/main/res/drawable/tomato_logo_notification.xml
Normal file
25
app/src/main/res/drawable/tomato_logo_notification.xml
Normal 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>
|
||||||
Reference in New Issue
Block a user