Merge branch 'dev'

This commit is contained in:
Nishant Mishra
2025-09-14 19:02:34 +05:30
21 changed files with 800 additions and 280 deletions

View File

@@ -9,7 +9,7 @@ jobs:
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v5
- name: Set up JDK 21 - name: Set up JDK 21
uses: actions/setup-java@v4 uses: actions/setup-java@v5
with: with:
java-version: '21' java-version: '21'
distribution: 'temurin' distribution: 'temurin'

View File

@@ -33,15 +33,16 @@ android {
applicationId = "org.nsh07.pomodoro" applicationId = "org.nsh07.pomodoro"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 5 versionCode = 6
versionName = "1.1.0" versionName = "1.2.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
buildTypes { buildTypes {
release { release {
isMinifyEnabled = false isMinifyEnabled = true
isShrinkResources = true
proguardFiles( proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
) )

View File

@@ -18,4 +18,6 @@
# If you keep the line number information, uncomment this to # If you keep the line number information, uncomment this to
# hide the original source file name. # hide the original source file name.
#-renamesourcefileattribute SourceFile #-renamesourcefileattribute SourceFile
-dontobfuscate

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),
@@ -160,6 +192,7 @@ fun AppScreen(
entry<Screen.Stats> { entry<Screen.Stats> {
StatsScreenRoot( StatsScreenRoot(
contentPadding = contentPadding,
viewModel = statsViewModel, viewModel = statsViewModel,
modifier = modifier.padding( modifier = modifier.padding(
start = contentPadding.calculateStartPadding(layoutDirection), start = contentPadding.calculateStartPadding(layoutDirection),

View File

@@ -24,6 +24,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import org.nsh07.pomodoro.ui.theme.AppFonts.openRundeClock import org.nsh07.pomodoro.ui.theme.AppFonts.openRundeClock
import org.nsh07.pomodoro.ui.theme.CustomColors.listItemColors
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
@@ -59,7 +60,7 @@ fun MinuteInputField(
.background( .background(
animateColorAsState( animateColorAsState(
if (state.text.isNotEmpty()) if (state.text.isNotEmpty())
colorScheme.surface listItemColors.containerColor
else colorScheme.errorContainer, else colorScheme.errorContainer,
motionScheme.defaultEffectsSpec() motionScheme.defaultEffectsSpec()
).value, ).value,

View File

@@ -60,6 +60,8 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import org.nsh07.pomodoro.R import org.nsh07.pomodoro.R
import org.nsh07.pomodoro.ui.settingsScreen.viewModel.SettingsViewModel import org.nsh07.pomodoro.ui.settingsScreen.viewModel.SettingsViewModel
import org.nsh07.pomodoro.ui.theme.AppFonts.robotoFlexTopBar import org.nsh07.pomodoro.ui.theme.AppFonts.robotoFlexTopBar
import org.nsh07.pomodoro.ui.theme.CustomColors.listItemColors
import org.nsh07.pomodoro.ui.theme.CustomColors.topBarColors
import org.nsh07.pomodoro.ui.theme.TomatoTheme import org.nsh07.pomodoro.ui.theme.TomatoTheme
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -120,7 +122,7 @@ private fun SettingsScreen(
) )
}, },
subtitle = {}, subtitle = {},
colors = TopAppBarDefaults.topAppBarColors(containerColor = colorScheme.surfaceContainer), colors = topBarColors,
titleHorizontalAlignment = Alignment.CenterHorizontally, titleHorizontalAlignment = Alignment.CenterHorizontally,
scrollBehavior = scrollBehavior scrollBehavior = scrollBehavior
) )
@@ -128,7 +130,7 @@ private fun SettingsScreen(
LazyColumn( LazyColumn(
verticalArrangement = Arrangement.spacedBy(2.dp), verticalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier modifier = Modifier
.background(colorScheme.surfaceContainer) .background(topBarColors.containerColor)
.fillMaxSize() .fillMaxSize()
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
) { ) {
@@ -221,6 +223,7 @@ private fun SettingsScreen(
) )
} }
}, },
colors = listItemColors,
modifier = Modifier.clip(shapes.large) modifier = Modifier.clip(shapes.large)
) )
} }

View File

@@ -12,8 +12,10 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -64,6 +66,7 @@ import org.nsh07.pomodoro.utils.millisecondsToHoursMinutes
@Composable @Composable
fun StatsScreenRoot( fun StatsScreenRoot(
contentPadding: PaddingValues,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: StatsViewModel = viewModel(factory = StatsViewModel.Factory) viewModel: StatsViewModel = viewModel(factory = StatsViewModel.Factory)
) { ) {
@@ -74,6 +77,7 @@ fun StatsScreenRoot(
.lastMonthAverageFocusTimes.collectAsStateWithLifecycle(null) .lastMonthAverageFocusTimes.collectAsStateWithLifecycle(null)
StatsScreen( StatsScreen(
contentPadding = contentPadding,
lastWeekSummaryChartData = remember { viewModel.lastWeekSummaryChartData }, lastWeekSummaryChartData = remember { viewModel.lastWeekSummaryChartData },
lastWeekSummaryAnalysisModelProducer = remember { viewModel.lastWeekSummaryAnalysisModelProducer }, lastWeekSummaryAnalysisModelProducer = remember { viewModel.lastWeekSummaryAnalysisModelProducer },
lastMonthSummaryChartData = remember { viewModel.lastMonthSummaryChartData }, lastMonthSummaryChartData = remember { viewModel.lastMonthSummaryChartData },
@@ -88,6 +92,7 @@ fun StatsScreenRoot(
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun StatsScreen( fun StatsScreen(
contentPadding: PaddingValues,
lastWeekSummaryChartData: Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>, lastWeekSummaryChartData: Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>,
lastWeekSummaryAnalysisModelProducer: CartesianChartModelProducer, lastWeekSummaryAnalysisModelProducer: CartesianChartModelProducer,
lastMonthSummaryChartData: Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>, lastMonthSummaryChartData: Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>,
@@ -114,12 +119,16 @@ fun StatsScreen(
fontFamily = robotoFlexTopBar, fontFamily = robotoFlexTopBar,
fontSize = 32.sp, fontSize = 32.sp,
lineHeight = 32.sp lineHeight = 32.sp
) ),
modifier = Modifier
.padding(top = contentPadding.calculateTopPadding())
.padding(vertical = 14.dp)
) )
}, },
subtitle = {}, subtitle = {},
titleHorizontalAlignment = Alignment.CenterHorizontally, titleHorizontalAlignment = Alignment.CenterHorizontally,
scrollBehavior = scrollBehavior scrollBehavior = scrollBehavior,
windowInsets = WindowInsets()
) )
LazyColumn( LazyColumn(
@@ -356,6 +365,7 @@ fun StatsScreenPreview() {
} }
StatsScreen( StatsScreen(
PaddingValues(),
Pair(modelProducer, ExtraStore.Key()), Pair(modelProducer, ExtraStore.Key()),
modelProducer, modelProducer,
Pair(modelProducer, ExtraStore.Key()), Pair(modelProducer, ExtraStore.Key()),

View File

@@ -1,5 +1,12 @@
package org.nsh07.pomodoro.ui.theme package org.nsh07.pomodoro.ui.theme
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ListItemColors
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.material3.TopAppBarColors
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF) val Purple80 = Color(0xFFD0BCFF)
@@ -8,4 +15,18 @@ val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4) val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71) val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260) val Pink40 = Color(0xFF7D5260)
object CustomColors {
@OptIn(ExperimentalMaterial3Api::class)
val topBarColors: TopAppBarColors
@Composable get() {
return TopAppBarDefaults.topAppBarColors(
containerColor = colorScheme.surfaceContainer,
scrolledContainerColor = colorScheme.surfaceContainer
)
}
val listItemColors: ListItemColors
@Composable get() = ListItemDefaults.colors(containerColor = colorScheme.surfaceBright)
}

View File

@@ -0,0 +1,77 @@
/*
* Copyright (c) 2025 Nishant Mishra
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.nsh07.pomodoro.ui.timerScreen
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.material3.AlertDialogDefaults
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MaterialTheme.typography
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import org.nsh07.pomodoro.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AlarmDialog(
modifier: Modifier = Modifier,
stopAlarm: () -> Unit
) {
BasicAlertDialog(
onDismissRequest = stopAlarm,
modifier = modifier
) {
Surface(
modifier = Modifier
.wrapContentWidth()
.wrapContentHeight()
.clickable(onClick = stopAlarm),
shape = MaterialTheme.shapes.extraLarge,
tonalElevation = AlertDialogDefaults.TonalElevation,
) {
Column(modifier = Modifier.padding(24.dp)) {
Icon(
painter = painterResource(R.drawable.alarm),
contentDescription = "Alarm",
modifier = Modifier.align(Alignment.CenterHorizontally)
)
Spacer(Modifier.height(16.dp))
Text(
text = "Stop Alarm?",
style = typography.headlineSmall,
modifier = Modifier.align(Alignment.CenterHorizontally)
)
Spacer(Modifier.height(16.dp))
Text(
text = "Current timer session is complete. Tap anywhere to stop the alarm."
)
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = stopAlarm,
modifier = Modifier.align(Alignment.End),
) {
Text("Ok")
}
}
}
}
}

View File

@@ -12,10 +12,16 @@ import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith import androidx.compose.animation.togetherWith
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -41,14 +47,20 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.material3.MaterialTheme.motionScheme import androidx.compose.material3.MaterialTheme.motionScheme
import androidx.compose.material3.MaterialTheme.shapes
import androidx.compose.material3.MaterialTheme.typography import androidx.compose.material3.MaterialTheme.typography
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
@@ -99,6 +111,9 @@ fun TimerScreen(
onResult = {} onResult = {}
) )
if (timerState.alarmRinging)
AlarmDialog { onAction(TimerAction.StopAlarm) }
Column(modifier = modifier) { Column(modifier = modifier) {
TopAppBar( TopAppBar(
title = { title = {
@@ -170,15 +185,15 @@ fun TimerScreen(
} }
}, },
subtitle = {}, subtitle = {},
titleHorizontalAlignment = Alignment.CenterHorizontally titleHorizontalAlignment = CenterHorizontally
) )
Column( Column(
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = CenterHorizontally,
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) { ) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = CenterHorizontally) {
Box(contentAlignment = Alignment.Center) { Box(contentAlignment = Alignment.Center) {
if (timerState.timerMode == TimerMode.FOCUS) { if (timerState.timerMode == TimerMode.FOCUS) {
CircularProgressIndicator( CircularProgressIndicator(
@@ -190,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(
@@ -214,20 +229,45 @@ fun TimerScreen(
cap = StrokeCap.Round, cap = StrokeCap.Round,
), ),
wavelength = 60.dp, wavelength = 60.dp,
gapSize = 16.dp gapSize = 8.dp
) )
} }
Text( var expanded by remember { mutableStateOf(timerState.showBrandTitle) }
text = timerState.timeStr, Column(
style = TextStyle( horizontalAlignment = CenterHorizontally,
fontFamily = openRundeClock, modifier = Modifier
fontWeight = FontWeight.Bold, .clip(shapes.largeIncreased)
fontSize = 72.sp, .clickable(onClick = { expanded = !expanded })
letterSpacing = (-2).sp ) {
), LaunchedEffect(timerState.showBrandTitle) {
textAlign = TextAlign.Center, expanded = timerState.showBrandTitle
maxLines = 1 }
) Text(
text = timerState.timeStr,
style = TextStyle(
fontFamily = openRundeClock,
fontWeight = FontWeight.Bold,
fontSize = 72.sp,
letterSpacing = (-2).sp
),
textAlign = TextAlign.Center,
maxLines = 1
)
AnimatedVisibility(
expanded,
enter = fadeIn(motionScheme.defaultEffectsSpec()) +
expandVertically(motionScheme.defaultSpatialSpec()),
exit = fadeOut(motionScheme.defaultEffectsSpec()) +
shrinkVertically(motionScheme.defaultSpatialSpec())
) {
Text(
"${timerState.currentFocusCount} of ${timerState.totalFocusCount}",
fontFamily = openRundeClock,
style = typography.titleLarge,
color = colorScheme.outline
)
}
}
} }
val interactionSources = remember { List(3) { MutableInteractionSource() } } val interactionSources = remember { List(3) { MutableInteractionSource() } }
ButtonGroup( ButtonGroup(
@@ -260,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(
@@ -355,7 +395,7 @@ fun TimerScreen(
customItem( customItem(
{ {
FilledTonalIconButton( FilledTonalIconButton(
onClick = { onAction(TimerAction.SkipTimer) }, onClick = { onAction(TimerAction.SkipTimer(fromButton = true)) },
colors = IconButtonDefaults.filledTonalIconButtonColors( colors = IconButtonDefaults.filledTonalIconButtonColors(
containerColor = colorContainer containerColor = colorContainer
), ),
@@ -382,7 +422,7 @@ fun TimerScreen(
}, },
text = { Text("Skip to next") }, text = { Text("Skip to next") },
onClick = { onClick = {
onAction(TimerAction.SkipTimer) onAction(TimerAction.SkipTimer(fromButton = true))
state.dismiss() state.dismiss()
} }
) )
@@ -393,7 +433,7 @@ fun TimerScreen(
Spacer(Modifier.height(32.dp)) Spacer(Modifier.height(32.dp))
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = CenterHorizontally) {
Text("Up next", style = typography.titleSmall) Text("Up next", style = typography.titleSmall)
Text( Text(
timerState.nextTimeStr, timerState.nextTimeStr,

View File

@@ -8,7 +8,9 @@
package org.nsh07.pomodoro.ui.timerScreen.viewModel package org.nsh07.pomodoro.ui.timerScreen.viewModel
sealed interface TimerAction { sealed interface TimerAction {
data class SkipTimer(val fromButton: Boolean) : TimerAction
data object ResetTimer : TimerAction data object ResetTimer : TimerAction
data object SkipTimer : TimerAction data object StopAlarm : TimerAction
data object ToggleTimer : TimerAction data object ToggleTimer : TimerAction
} }

View File

@@ -14,7 +14,10 @@ data class TimerState(
val timerRunning: Boolean = false, val timerRunning: Boolean = false,
val nextTimerMode: TimerMode = TimerMode.SHORT_BREAK, val nextTimerMode: TimerMode = TimerMode.SHORT_BREAK,
val nextTimeStr: String = "5:00", val nextTimeStr: String = "5:00",
val showBrandTitle: Boolean = true val showBrandTitle: Boolean = true,
val currentFocusCount: Int = 1,
val totalFocusCount: Int = 4,
val alarmRinging: Boolean = false
) )
enum class TimerMode { enum class TimerMode {

View File

@@ -7,18 +7,9 @@
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.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Intent
import android.os.SystemClock
import androidx.compose.material3.ColorScheme import androidx.compose.material3.ColorScheme
import androidx.compose.ui.graphics.Color import androidx.lifecycle.AndroidViewModel
import androidx.compose.ui.graphics.toArgb
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
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
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@@ -26,15 +17,12 @@ 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
@@ -42,27 +30,17 @@ 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(
application: Application,
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>
) : ViewModel() { ) : 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
@@ -122,14 +100,6 @@ class TimerViewModel(
cs = colorScheme cs = colorScheme
} }
fun onAction(action: TimerAction) {
when (action) {
TimerAction.ResetTimer -> resetTimer()
TimerAction.SkipTimer -> skipTimer()
TimerAction.ToggleTimer -> toggleTimer()
}
}
private fun resetTimer() { private fun resetTimer() {
viewModelScope.launch { viewModelScope.launch {
saveTimeToDb() saveTimeToDb()
@@ -145,111 +115,14 @@ class TimerViewModel(
timeStr = millisecondsToStr(time.value), timeStr = millisecondsToStr(time.value),
totalTime = time.value, totalTime = time.value,
nextTimerMode = if (timerRepository.sessionLength > 1) TimerMode.SHORT_BREAK else TimerMode.LONG_BREAK, nextTimerMode = if (timerRepository.sessionLength > 1) TimerMode.SHORT_BREAK else TimerMode.LONG_BREAK,
nextTimeStr = millisecondsToStr(if (timerRepository.sessionLength > 1) timerRepository.shortBreakTime else timerRepository.longBreakTime) nextTimeStr = millisecondsToStr(if (timerRepository.sessionLength > 1) timerRepository.shortBreakTime else timerRepository.longBreakTime),
currentFocusCount = 1,
totalFocusCount = timerRepository.sessionLength
) )
} }
} }
} }
private fun skipTimer() {
viewModelScope.launch {
saveTimeToDb()
showTimerNotification(0, paused = true, complete = true)
startTime = 0L
pauseTime = 0L
pauseDuration = 0L
cycles = (cycles + 1) % (timerRepository.sessionLength * 2)
if (cycles % 2 == 0) {
_time.update { timerRepository.focusTime }
_timerState.update { currentState ->
currentState.copy(
timerMode = TimerMode.FOCUS,
timeStr = millisecondsToStr(time.value),
totalTime = time.value,
nextTimerMode = if (cycles == (timerRepository.sessionLength - 1) * 2) TimerMode.LONG_BREAK else TimerMode.SHORT_BREAK,
nextTimeStr = if (cycles == (timerRepository.sessionLength - 1) * 2) millisecondsToStr(
timerRepository.longBreakTime
) else millisecondsToStr(
timerRepository.shortBreakTime
)
)
}
} else {
val long = cycles == (timerRepository.sessionLength * 2) - 1
_time.update { if (long) timerRepository.longBreakTime else timerRepository.shortBreakTime }
_timerState.update { currentState ->
currentState.copy(
timerMode = if (long) TimerMode.LONG_BREAK else TimerMode.SHORT_BREAK,
timeStr = millisecondsToStr(time.value),
totalTime = time.value,
nextTimerMode = TimerMode.FOCUS,
nextTimeStr = millisecondsToStr(timerRepository.focusTime)
)
}
}
}
}
private fun toggleTimer() {
if (timerState.value.timerRunning) {
showTimerNotification(time.value.toInt(), paused = true)
_timerState.update { currentState ->
currentState.copy(timerRunning = false)
}
timerJob?.cancel()
pauseTime = SystemClock.elapsedRealtime()
} else {
_timerState.update { it.copy(timerRunning = true) }
if (pauseTime != 0L) pauseDuration += SystemClock.elapsedRealtime() - pauseTime
var iterations = -1
timerJob = viewModelScope.launch {
while (true) {
if (!timerState.value.timerRunning) break
if (startTime == 0L) startTime = SystemClock.elapsedRealtime()
_time.update {
when (timerState.value.timerMode) {
TimerMode.FOCUS ->
timerRepository.focusTime - (SystemClock.elapsedRealtime() - startTime - pauseDuration).toInt()
TimerMode.SHORT_BREAK ->
timerRepository.shortBreakTime - (SystemClock.elapsedRealtime() - startTime - pauseDuration).toInt()
else ->
timerRepository.longBreakTime - (SystemClock.elapsedRealtime() - startTime - pauseDuration).toInt()
}
}
iterations = (iterations + 1) % 50
if (iterations == 0) showTimerNotification(time.value.toInt())
if (time.value < 0) {
skipTimer()
_timerState.update { currentState ->
currentState.copy(timerRunning = false)
}
timerJob?.cancel()
} else {
_timerState.update { currentState ->
currentState.copy(
timeStr = millisecondsToStr(time.value)
)
}
}
delay(100)
}
}
}
}
suspend fun saveTimeToDb() { suspend fun saveTimeToDb() {
when (timerState.value.timerMode) { when (timerState.value.timerMode) {
TimerMode.FOCUS -> statRepository TimerMode.FOCUS -> statRepository
@@ -260,78 +133,6 @@ class TimerViewModel(
} }
} }
@SuppressLint("MissingPermission") // We check for the permission when pressing the Play button in the UI
fun showTimerNotification(
remainingTime: Int,
paused: Boolean = false,
complete: Boolean = false
) {
val totalTime = when (timerState.value.timerMode) {
TimerMode.FOCUS -> timerRepository.focusTime.toInt()
TimerMode.SHORT_BREAK -> timerRepository.shortBreakTime.toInt()
else -> timerRepository.longBreakTime.toInt()
}
val currentTimer = when (timerState.value.timerMode) {
TimerMode.FOCUS -> "Focus"
TimerMode.SHORT_BREAK -> "Short break"
else -> "Long break"
}
val nextTimer = when (timerState.value.nextTimerMode) {
TimerMode.FOCUS -> "Focus"
TimerMode.SHORT_BREAK -> "Short break"
else -> "Long break"
}
val remainingTimeString =
if ((remainingTime.toFloat() / 60000f) < 1.0f) "< 1"
else (remainingTime.toFloat() / 60000f).toInt()
notificationManager.notify(
1,
notificationBuilder
.setContentTitle(
if (!complete) {
"$currentTimer $middleDot $remainingTimeString min remaining" + if (paused) " $middleDot Paused" else ""
} else "$currentTimer $middleDot Completed"
)
.setContentText("Up next: $nextTimer (${timerState.value.nextTimeStr})")
.setStyle(
NotificationCompat.ProgressStyle()
.also {
// Add all the Focus, Short break and long break intervals in order
for (i in 0..<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 {
@@ -339,42 +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,
preferenceRepository = appPreferenceRepository, preferenceRepository = appPreferenceRepository,
statRepository = appStatRepository, statRepository = appStatRepository,
timerRepository = appTimerRepository, timerRepository = appTimerRepository,
notificationBuilder = notificationBuilder, _timerState = timerState,
notificationManager = notificationManager _time = time
) )
} }
} }

View File

@@ -0,0 +1,16 @@
<!--
~ 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="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#e3e3e3"
android:pathData="M520,504v-144q0,-17 -11.5,-28.5T480,320q-17,0 -28.5,11.5T440,360v159q0,8 3,15.5t9,13.5l112,112q11,11 28,11t28,-11q11,-11 11,-28t-11,-28L520,504ZM480,880q-75,0 -140.5,-28.5t-114,-77q-48.5,-48.5 -77,-114T120,520q0,-75 28.5,-140.5t77,-114q48.5,-48.5 114,-77T480,160q75,0 140.5,28.5t114,77q48.5,48.5 77,114T840,520q0,75 -28.5,140.5t-77,114q-48.5,48.5 -114,77T480,880ZM82,292q-11,-11 -11,-28t11,-28l114,-114q11,-11 28,-11t28,11q11,11 11,28t-11,28L138,292q-11,11 -28,11t-28,-11ZM878,292q-11,11 -28,11t-28,-11L708,178q-11,-11 -11,-28t11,-28q11,-11 28,-11t28,11l114,114q11,11 11,28t-11,28Z" />
</vector>

View File

@@ -0,0 +1,9 @@
New features:
- The app now rings the system alarm sound when a timer completes
- Clicking the timer clock now shows the current session count
- The app can now stay in the background and the timer keeps running even when the app is closed
- New notification buttons to control the timer without opening the app
Fixes:
- Current elapsed time is now saved in the stats when the app is closed while a timer is running
- Live Updates/Now Bar now shows precisely how much time is remaining (in seconds)

View File

@@ -1,17 +1,17 @@
[versions] [versions]
activityCompose = "1.10.1" activityCompose = "1.11.0"
adaptive = "1.1.0" adaptive = "1.1.0"
agp = "8.11.1" agp = "8.11.1"
composeBom = "2025.08.00" composeBom = "2025.09.00"
coreKtx = "1.17.0" coreKtx = "1.17.0"
espressoCore = "3.7.0" espressoCore = "3.7.0"
junit = "4.13.2" junit = "4.13.2"
junitVersion = "1.3.0" junitVersion = "1.3.0"
kotlin = "2.2.10" kotlin = "2.2.20"
ksp = "2.2.10-2.0.2" ksp = "2.2.20-2.0.3"
lifecycleRuntimeKtx = "2.9.2" lifecycleRuntimeKtx = "2.9.3"
navigation3Runtime = "1.0.0-alpha07" navigation3Runtime = "1.0.0-alpha09"
room = "2.7.2" room = "2.8.0"
vico = "2.2.0-alpha.1" vico = "2.2.0-alpha.1"
[libraries] [libraries]