Merge pull request #32 from nsh07/foreground-service

feat: Implement a foreground service to run the timer
This commit is contained in:
Nishant Mishra
2025-09-14 17:08:40 +05:30
committed by GitHub
8 changed files with 569 additions and 267 deletions

View File

@@ -2,6 +2,8 @@
<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.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.POST_PROMOTED_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_PROMOTED_NOTIFICATIONS" />
@@ -26,6 +28,13 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<service
android:name=".service.TimerService"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Required to keep the timer running in the background" />
</service>
</application> </application>
</manifest> </manifest>

View File

@@ -1,6 +1,8 @@
package org.nsh07.pomodoro package org.nsh07.pomodoro
import android.app.Application import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
import org.nsh07.pomodoro.data.AppContainer import org.nsh07.pomodoro.data.AppContainer
import org.nsh07.pomodoro.data.DefaultAppContainer import org.nsh07.pomodoro.data.DefaultAppContainer
@@ -9,5 +11,13 @@ class TomatoApplication : Application() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
container = DefaultAppContainer(this) container = DefaultAppContainer(this)
val notificationChannel = NotificationChannel(
"timer",
"Timer progress",
NotificationManager.IMPORTANCE_HIGH
)
container.notificationManager.createNotificationChannel(notificationChannel)
} }
} }

View File

@@ -7,12 +7,26 @@
package org.nsh07.pomodoro.data package org.nsh07.pomodoro.data
import android.app.PendingIntent
import android.content.Context 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 { interface AppContainer {
val appPreferenceRepository: AppPreferenceRepository val appPreferenceRepository: AppPreferenceRepository
val appStatRepository: AppStatRepository val appStatRepository: AppStatRepository
val appTimerRepository: AppTimerRepository val appTimerRepository: AppTimerRepository
val notificationManager: NotificationManagerCompat
val notificationBuilder: NotificationCompat.Builder
val timerState: MutableStateFlow<TimerState>
val time: MutableStateFlow<Long>
} }
class DefaultAppContainer(context: Context) : AppContainer { class DefaultAppContainer(context: Context) : AppContainer {
@@ -27,4 +41,41 @@ class DefaultAppContainer(context: Context) : AppContainer {
override val appTimerRepository: AppTimerRepository by lazy { AppTimerRepository() } 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<TimerState> by lazy {
MutableStateFlow(
TimerState(
totalTime = appTimerRepository.focusTime,
timeStr = millisecondsToStr(appTimerRepository.focusTime),
nextTimeStr = millisecondsToStr(appTimerRepository.shortBreakTime)
)
)
}
override val time: MutableStateFlow<Long> by lazy {
MutableStateFlow(appTimerRepository.focusTime)
}
} }

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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
)
)

View File

@@ -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<TimerState>
private lateinit var _time: MutableStateFlow<Long>
val timeStateFlow by lazy {
_time.asStateFlow()
}
var time: Long
get() = timeStateFlow.value
set(value) = _time.update { value }
lateinit var timerState: StateFlow<TimerState>
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.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())
)
}
} else {
it.addProgressSegment(
NotificationCompat.ProgressStyle.Segment(
when (timerState.value.timerMode) {
TimerMode.FOCUS -> 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
}
}

View File

@@ -7,6 +7,7 @@
package org.nsh07.pomodoro.ui package org.nsh07.pomodoro.ui
import android.content.Intent
import androidx.compose.animation.ContentTransform import androidx.compose.animation.ContentTransform
import androidx.compose.animation.Crossfade import androidx.compose.animation.Crossfade
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
@@ -33,6 +34,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
@@ -44,10 +46,12 @@ import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay import androidx.navigation3.ui.NavDisplay
import androidx.window.core.layout.WindowSizeClass import androidx.window.core.layout.WindowSizeClass
import org.nsh07.pomodoro.MainActivity.Companion.screens 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.settingsScreen.SettingsScreenRoot
import org.nsh07.pomodoro.ui.statsScreen.StatsScreenRoot import org.nsh07.pomodoro.ui.statsScreen.StatsScreenRoot
import org.nsh07.pomodoro.ui.statsScreen.viewModel.StatsViewModel import org.nsh07.pomodoro.ui.statsScreen.viewModel.StatsViewModel
import org.nsh07.pomodoro.ui.timerScreen.TimerScreen import org.nsh07.pomodoro.ui.timerScreen.TimerScreen
import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerAction
import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerViewModel import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerViewModel
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@@ -57,6 +61,8 @@ fun AppScreen(
timerViewModel: TimerViewModel = viewModel(factory = TimerViewModel.Factory), timerViewModel: TimerViewModel = viewModel(factory = TimerViewModel.Factory),
statsViewModel: StatsViewModel = viewModel(factory = StatsViewModel.Factory) statsViewModel: StatsViewModel = viewModel(factory = StatsViewModel.Factory)
) { ) {
val context = LocalContext.current
val uiState by timerViewModel.timerState.collectAsStateWithLifecycle() val uiState by timerViewModel.timerState.collectAsStateWithLifecycle()
val remainingTime by timerViewModel.time.collectAsStateWithLifecycle() val remainingTime by timerViewModel.time.collectAsStateWithLifecycle()
@@ -139,7 +145,33 @@ fun AppScreen(
TimerScreen( TimerScreen(
timerState = uiState, timerState = uiState,
progress = { progress }, 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( modifier = modifier.padding(
start = contentPadding.calculateStartPadding(layoutDirection), start = contentPadding.calculateStartPadding(layoutDirection),
end = contentPadding.calculateEndPadding(layoutDirection), end = contentPadding.calculateEndPadding(layoutDirection),

View File

@@ -205,7 +205,7 @@ fun TimerScreen(
color = color, color = color,
trackColor = colorContainer, trackColor = colorContainer,
strokeWidth = 16.dp, strokeWidth = 16.dp,
gapSize = 16.dp gapSize = 8.dp
) )
} else { } else {
CircularWavyProgressIndicator( CircularWavyProgressIndicator(
@@ -229,7 +229,7 @@ fun TimerScreen(
cap = StrokeCap.Round, cap = StrokeCap.Round,
), ),
wavelength = 60.dp, wavelength = 60.dp,
gapSize = 16.dp gapSize = 8.dp
) )
} }
var expanded by remember { mutableStateOf(timerState.showBrandTitle) } var expanded by remember { mutableStateOf(timerState.showBrandTitle) }
@@ -300,10 +300,10 @@ fun TimerScreen(
{ {
FilledIconToggleButton( FilledIconToggleButton(
onCheckedChange = { checked -> onCheckedChange = { checked ->
onAction(TimerAction.ToggleTimer)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && checked) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && checked) {
permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
} }
onAction(TimerAction.ToggleTimer)
}, },
checked = timerState.timerRunning, checked = timerState.timerRunning,
colors = IconButtonDefaults.filledIconToggleButtonColors( colors = IconButtonDefaults.filledIconToggleButtonColors(

View File

@@ -7,38 +7,22 @@
package org.nsh07.pomodoro.ui.timerScreen.viewModel package org.nsh07.pomodoro.ui.timerScreen.viewModel
import android.annotation.SuppressLint
import android.app.Application 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.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.AndroidViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
import androidx.lifecycle.application
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory import androidx.lifecycle.viewmodel.viewModelFactory
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow 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
@@ -46,7 +30,6 @@ 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(
@@ -54,20 +37,10 @@ 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 _timerState: MutableStateFlow<TimerState>,
private val notificationManager: NotificationManagerCompat private val _time: MutableStateFlow<Long>
) : AndroidViewModel(application) { ) : AndroidViewModel(application) {
private val _timerState = MutableStateFlow(
TimerState(
totalTime = timerRepository.focusTime,
timeStr = millisecondsToStr(timerRepository.focusTime),
nextTimeStr = millisecondsToStr(timerRepository.shortBreakTime)
)
)
val timerState: StateFlow<TimerState> = _timerState.asStateFlow() val timerState: StateFlow<TimerState> = _timerState.asStateFlow()
var timerJob: Job? = null
private val _time = MutableStateFlow(timerRepository.focusTime)
val time: StateFlow<Long> = _time.asStateFlow() val time: StateFlow<Long> = _time.asStateFlow()
private var cycles = 0 private var cycles = 0
@@ -78,11 +51,6 @@ class TimerViewModel(
private lateinit var cs: ColorScheme private lateinit var cs: ColorScheme
private val alarm = MediaPlayer.create(
this.application,
Settings.System.DEFAULT_ALARM_ALERT_URI ?: Settings.System.DEFAULT_RINGTONE_URI
)
init { init {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
timerRepository.focusTime = timerRepository.focusTime =
@@ -132,16 +100,6 @@ class TimerViewModel(
cs = colorScheme 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() { private fun resetTimer() {
viewModelScope.launch { viewModelScope.launch {
saveTimeToDb() 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() { suspend fun saveTimeToDb() {
when (timerState.value.timerMode) { when (timerState.value.timerMode) {
TimerMode.FOCUS -> statRepository 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..<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(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 { companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory { val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer { initializer {
@@ -370,43 +140,16 @@ class TimerViewModel(
val appPreferenceRepository = application.container.appPreferenceRepository val appPreferenceRepository = application.container.appPreferenceRepository
val appStatRepository = application.container.appStatRepository val appStatRepository = application.container.appStatRepository
val appTimerRepository = application.container.appTimerRepository val appTimerRepository = application.container.appTimerRepository
val timerState = application.container.timerState
val notificationManager = NotificationManagerCompat.from(application) val time = application.container.time
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(
application = application, application = application,
preferenceRepository = appPreferenceRepository, preferenceRepository = appPreferenceRepository,
statRepository = appStatRepository, statRepository = appStatRepository,
timerRepository = appTimerRepository, timerRepository = appTimerRepository,
notificationBuilder = notificationBuilder, _timerState = timerState,
notificationManager = notificationManager _time = time
) )
} }
} }