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"
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_PROMOTED_NOTIFICATIONS" />
@@ -26,6 +28,13 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</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>
</manifest>

View File

@@ -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)
}
}

View File

@@ -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<TimerState>
val time: MutableStateFlow<Long>
}
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<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
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),

View File

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

View File

@@ -7,38 +7,22 @@
package org.nsh07.pomodoro.ui.timerScreen.viewModel
import android.annotation.SuppressLint
import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Intent
import android.media.MediaPlayer
import android.os.SystemClock
import android.provider.Settings
import androidx.compose.material3.ColorScheme
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
import androidx.lifecycle.application
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.nsh07.pomodoro.MainActivity
import org.nsh07.pomodoro.R
import org.nsh07.pomodoro.TomatoApplication
import org.nsh07.pomodoro.data.PreferenceRepository
import org.nsh07.pomodoro.data.Stat
@@ -46,7 +30,6 @@ import org.nsh07.pomodoro.data.StatRepository
import org.nsh07.pomodoro.data.TimerRepository
import org.nsh07.pomodoro.utils.millisecondsToStr
import java.time.LocalDate
import kotlin.text.Typography.middleDot
@OptIn(FlowPreview::class)
class TimerViewModel(
@@ -54,20 +37,10 @@ class TimerViewModel(
private val preferenceRepository: PreferenceRepository,
private val statRepository: StatRepository,
private val timerRepository: TimerRepository,
private val notificationBuilder: NotificationCompat.Builder,
private val notificationManager: NotificationManagerCompat
private val _timerState: MutableStateFlow<TimerState>,
private val _time: MutableStateFlow<Long>
) : AndroidViewModel(application) {
private val _timerState = MutableStateFlow(
TimerState(
totalTime = timerRepository.focusTime,
timeStr = millisecondsToStr(timerRepository.focusTime),
nextTimeStr = millisecondsToStr(timerRepository.shortBreakTime)
)
)
val timerState: StateFlow<TimerState> = _timerState.asStateFlow()
var timerJob: Job? = null
private val _time = MutableStateFlow(timerRepository.focusTime)
val time: StateFlow<Long> = _time.asStateFlow()
private var cycles = 0
@@ -78,11 +51,6 @@ class TimerViewModel(
private lateinit var cs: ColorScheme
private val alarm = MediaPlayer.create(
this.application,
Settings.System.DEFAULT_ALARM_ALERT_URI ?: Settings.System.DEFAULT_RINGTONE_URI
)
init {
viewModelScope.launch(Dispatchers.IO) {
timerRepository.focusTime =
@@ -132,16 +100,6 @@ class TimerViewModel(
cs = colorScheme
}
fun onAction(action: TimerAction) {
when (action) {
is TimerAction.SkipTimer -> skipTimer(action.fromButton)
TimerAction.ResetTimer -> resetTimer()
TimerAction.StopAlarm -> stopAlarm()
TimerAction.ToggleTimer -> toggleTimer()
}
}
private fun resetTimer() {
viewModelScope.launch {
saveTimeToDb()
@@ -165,107 +123,6 @@ class TimerViewModel(
}
}
private fun skipTimer(fromButton: Boolean = false) {
viewModelScope.launch {
saveTimeToDb()
showTimerNotification(0, paused = true, complete = !fromButton)
startTime = 0L
pauseTime = 0L
pauseDuration = 0L
cycles = (cycles + 1) % (timerRepository.sessionLength * 2)
if (cycles % 2 == 0) {
_time.update { timerRepository.focusTime }
_timerState.update { currentState ->
currentState.copy(
timerMode = TimerMode.FOCUS,
timeStr = millisecondsToStr(time.value),
totalTime = time.value,
nextTimerMode = if (cycles == (timerRepository.sessionLength - 1) * 2) TimerMode.LONG_BREAK else TimerMode.SHORT_BREAK,
nextTimeStr = if (cycles == (timerRepository.sessionLength - 1) * 2) millisecondsToStr(
timerRepository.longBreakTime
) else millisecondsToStr(
timerRepository.shortBreakTime
),
currentFocusCount = cycles / 2 + 1,
totalFocusCount = timerRepository.sessionLength
)
}
} else {
val long = cycles == (timerRepository.sessionLength * 2) - 1
_time.update { if (long) timerRepository.longBreakTime else timerRepository.shortBreakTime }
_timerState.update { currentState ->
currentState.copy(
timerMode = if (long) TimerMode.LONG_BREAK else TimerMode.SHORT_BREAK,
timeStr = millisecondsToStr(time.value),
totalTime = time.value,
nextTimerMode = TimerMode.FOCUS,
nextTimeStr = millisecondsToStr(timerRepository.focusTime)
)
}
}
}
}
private fun toggleTimer() {
if (timerState.value.timerRunning) {
showTimerNotification(time.value.toInt(), paused = true)
_timerState.update { currentState ->
currentState.copy(timerRunning = false)
}
timerJob?.cancel()
pauseTime = SystemClock.elapsedRealtime()
} else {
_timerState.update { it.copy(timerRunning = true) }
if (pauseTime != 0L) pauseDuration += SystemClock.elapsedRealtime() - pauseTime
var iterations = -1
timerJob = viewModelScope.launch {
while (true) {
if (!timerState.value.timerRunning) break
if (startTime == 0L) startTime = SystemClock.elapsedRealtime()
_time.update {
when (timerState.value.timerMode) {
TimerMode.FOCUS ->
timerRepository.focusTime - (SystemClock.elapsedRealtime() - startTime - pauseDuration).toInt()
TimerMode.SHORT_BREAK ->
timerRepository.shortBreakTime - (SystemClock.elapsedRealtime() - startTime - pauseDuration).toInt()
else ->
timerRepository.longBreakTime - (SystemClock.elapsedRealtime() - startTime - pauseDuration).toInt()
}
}
iterations = (iterations + 1) % 50
if (iterations == 0) showTimerNotification(time.value.toInt())
if (time.value < 0) {
skipTimer()
_timerState.update { currentState ->
currentState.copy(timerRunning = false)
}
timerJob?.cancel()
} else {
_timerState.update { currentState ->
currentState.copy(
timeStr = millisecondsToStr(time.value)
)
}
}
delay(100)
}
}
}
}
suspend fun saveTimeToDb() {
when (timerState.value.timerMode) {
TimerMode.FOCUS -> statRepository
@@ -276,93 +133,6 @@ class TimerViewModel(
}
}
@SuppressLint("MissingPermission") // We check for the permission when pressing the Play button in the UI
fun showTimerNotification(
remainingTime: Int,
paused: Boolean = false,
complete: Boolean = false
) {
val totalTime = when (timerState.value.timerMode) {
TimerMode.FOCUS -> timerRepository.focusTime.toInt()
TimerMode.SHORT_BREAK -> timerRepository.shortBreakTime.toInt()
else -> timerRepository.longBreakTime.toInt()
}
val currentTimer = when (timerState.value.timerMode) {
TimerMode.FOCUS -> "Focus"
TimerMode.SHORT_BREAK -> "Short break"
else -> "Long break"
}
val nextTimer = when (timerState.value.nextTimerMode) {
TimerMode.FOCUS -> "Focus"
TimerMode.SHORT_BREAK -> "Short break"
else -> "Long break"
}
val remainingTimeString =
if ((remainingTime.toFloat() / 60000f) < 1.0f) "< 1"
else (remainingTime.toFloat() / 60000f).toInt()
notificationManager.notify(
1,
notificationBuilder
.setContentTitle(
if (!complete) {
"$currentTimer $middleDot $remainingTimeString min remaining" + if (paused) " $middleDot Paused" else ""
} else "$currentTimer $middleDot Completed"
)
.setContentText("Up next: $nextTimer (${timerState.value.nextTimeStr})")
.setStyle(
NotificationCompat.ProgressStyle()
.also {
// Add all the Focus, Short break and long break intervals in order
for (i in 0..<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 {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
@@ -370,43 +140,16 @@ class TimerViewModel(
val appPreferenceRepository = application.container.appPreferenceRepository
val appStatRepository = application.container.appStatRepository
val appTimerRepository = application.container.appTimerRepository
val notificationManager = NotificationManagerCompat.from(application)
val notificationChannel = NotificationChannel(
"timer",
"Timer progress",
NotificationManager.IMPORTANCE_HIGH
)
notificationManager.createNotificationChannel(notificationChannel)
val openAppIntent = Intent(application, MainActivity::class.java)
openAppIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
openAppIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
val contentIntent = PendingIntent.getActivity(
application,
System.currentTimeMillis().toInt(),
openAppIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notificationBuilder = NotificationCompat.Builder(application, "timer")
.setSmallIcon(R.drawable.tomato_logo_notification)
.setOngoing(true)
.setColor(Color.Red.toArgb())
.setContentIntent(contentIntent)
.setRequestPromotedOngoing(true)
.setOngoing(true)
val timerState = application.container.timerState
val time = application.container.time
TimerViewModel(
application = application,
preferenceRepository = appPreferenceRepository,
statRepository = appStatRepository,
timerRepository = appTimerRepository,
notificationBuilder = notificationBuilder,
notificationManager = notificationManager
_timerState = timerState,
_time = time
)
}
}