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:
- uses: actions/checkout@v5
- name: Set up JDK 21
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
java-version: '21'
distribution: 'temurin'

View File

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

View File

@@ -18,4 +18,6 @@
# If you keep the line number information, uncomment this to
# 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"
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),
@@ -160,6 +192,7 @@ fun AppScreen(
entry<Screen.Stats> {
StatsScreenRoot(
contentPadding = contentPadding,
viewModel = statsViewModel,
modifier = modifier.padding(
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.sp
import org.nsh07.pomodoro.ui.theme.AppFonts.openRundeClock
import org.nsh07.pomodoro.ui.theme.CustomColors.listItemColors
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
@@ -59,7 +60,7 @@ fun MinuteInputField(
.background(
animateColorAsState(
if (state.text.isNotEmpty())
colorScheme.surface
listItemColors.containerColor
else colorScheme.errorContainer,
motionScheme.defaultEffectsSpec()
).value,

View File

@@ -60,6 +60,8 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import org.nsh07.pomodoro.R
import org.nsh07.pomodoro.ui.settingsScreen.viewModel.SettingsViewModel
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
@OptIn(ExperimentalMaterial3Api::class)
@@ -120,7 +122,7 @@ private fun SettingsScreen(
)
},
subtitle = {},
colors = TopAppBarDefaults.topAppBarColors(containerColor = colorScheme.surfaceContainer),
colors = topBarColors,
titleHorizontalAlignment = Alignment.CenterHorizontally,
scrollBehavior = scrollBehavior
)
@@ -128,7 +130,7 @@ private fun SettingsScreen(
LazyColumn(
verticalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier
.background(colorScheme.surfaceContainer)
.background(topBarColors.containerColor)
.fillMaxSize()
.padding(horizontal = 16.dp)
) {
@@ -221,6 +223,7 @@ private fun SettingsScreen(
)
}
},
colors = listItemColors,
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.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@@ -64,6 +66,7 @@ import org.nsh07.pomodoro.utils.millisecondsToHoursMinutes
@Composable
fun StatsScreenRoot(
contentPadding: PaddingValues,
modifier: Modifier = Modifier,
viewModel: StatsViewModel = viewModel(factory = StatsViewModel.Factory)
) {
@@ -74,6 +77,7 @@ fun StatsScreenRoot(
.lastMonthAverageFocusTimes.collectAsStateWithLifecycle(null)
StatsScreen(
contentPadding = contentPadding,
lastWeekSummaryChartData = remember { viewModel.lastWeekSummaryChartData },
lastWeekSummaryAnalysisModelProducer = remember { viewModel.lastWeekSummaryAnalysisModelProducer },
lastMonthSummaryChartData = remember { viewModel.lastMonthSummaryChartData },
@@ -88,6 +92,7 @@ fun StatsScreenRoot(
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun StatsScreen(
contentPadding: PaddingValues,
lastWeekSummaryChartData: Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>,
lastWeekSummaryAnalysisModelProducer: CartesianChartModelProducer,
lastMonthSummaryChartData: Pair<CartesianChartModelProducer, ExtraStore.Key<List<String>>>,
@@ -114,12 +119,16 @@ fun StatsScreen(
fontFamily = robotoFlexTopBar,
fontSize = 32.sp,
lineHeight = 32.sp
)
),
modifier = Modifier
.padding(top = contentPadding.calculateTopPadding())
.padding(vertical = 14.dp)
)
},
subtitle = {},
titleHorizontalAlignment = Alignment.CenterHorizontally,
scrollBehavior = scrollBehavior
scrollBehavior = scrollBehavior,
windowInsets = WindowInsets()
)
LazyColumn(
@@ -356,6 +365,7 @@ fun StatsScreenPreview() {
}
StatsScreen(
PaddingValues(),
Pair(modelProducer, ExtraStore.Key()),
modelProducer,
Pair(modelProducer, ExtraStore.Key()),

View File

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

View File

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

View File

@@ -14,7 +14,10 @@ data class TimerState(
val timerRunning: Boolean = false,
val nextTimerMode: TimerMode = TimerMode.SHORT_BREAK,
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 {

View File

@@ -7,18 +7,9 @@
package org.nsh07.pomodoro.ui.timerScreen.viewModel
import android.annotation.SuppressLint
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Intent
import android.os.SystemClock
import android.app.Application
import androidx.compose.material3.ColorScheme
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.lifecycle.ViewModel
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
import androidx.lifecycle.viewModelScope
@@ -26,15 +17,12 @@ 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
@@ -42,27 +30,17 @@ 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(
application: Application,
private val preferenceRepository: PreferenceRepository,
private val statRepository: StatRepository,
private val timerRepository: TimerRepository,
private val notificationBuilder: NotificationCompat.Builder,
private val notificationManager: NotificationManagerCompat
) : ViewModel() {
private val _timerState = MutableStateFlow(
TimerState(
totalTime = timerRepository.focusTime,
timeStr = millisecondsToStr(timerRepository.focusTime),
nextTimeStr = millisecondsToStr(timerRepository.shortBreakTime)
)
)
private val _timerState: MutableStateFlow<TimerState>,
private val _time: MutableStateFlow<Long>
) : AndroidViewModel(application) {
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
@@ -122,14 +100,6 @@ class TimerViewModel(
cs = colorScheme
}
fun onAction(action: TimerAction) {
when (action) {
TimerAction.ResetTimer -> resetTimer()
TimerAction.SkipTimer -> skipTimer()
TimerAction.ToggleTimer -> toggleTimer()
}
}
private fun resetTimer() {
viewModelScope.launch {
saveTimeToDb()
@@ -145,111 +115,14 @@ class TimerViewModel(
timeStr = millisecondsToStr(time.value),
totalTime = time.value,
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() {
when (timerState.value.timerMode) {
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 {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
@@ -339,42 +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
)
}
}

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]
activityCompose = "1.10.1"
activityCompose = "1.11.0"
adaptive = "1.1.0"
agp = "8.11.1"
composeBom = "2025.08.00"
composeBom = "2025.09.00"
coreKtx = "1.17.0"
espressoCore = "3.7.0"
junit = "4.13.2"
junitVersion = "1.3.0"
kotlin = "2.2.10"
ksp = "2.2.10-2.0.2"
lifecycleRuntimeKtx = "2.9.2"
navigation3Runtime = "1.0.0-alpha07"
room = "2.7.2"
kotlin = "2.2.20"
ksp = "2.2.20-2.0.3"
lifecycleRuntimeKtx = "2.9.3"
navigation3Runtime = "1.0.0-alpha09"
room = "2.8.0"
vico = "2.2.0-alpha.1"
[libraries]