Merge branch 'dev'

This commit is contained in:
Nishant Mishra
2025-09-16 10:13:36 +05:30
32 changed files with 789 additions and 149 deletions

View File

@@ -33,8 +33,8 @@ android {
applicationId = "org.nsh07.pomodoro"
minSdk = 26
targetSdk = 36
versionCode = 6
versionName = "1.2.0"
versionCode = 7
versionName = "1.3.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

View File

@@ -0,0 +1,133 @@
{
"formatVersion": 1,
"database": {
"version": 2,
"identityHash": "4691d636a1c16c8cd33dc1bf0602190c",
"entities": [
{
"tableName": "int_preference",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))",
"fields": [
{
"fieldPath": "key",
"columnName": "key",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "value",
"columnName": "value",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"key"
]
}
},
{
"tableName": "boolean_preference",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`key`))",
"fields": [
{
"fieldPath": "key",
"columnName": "key",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "value",
"columnName": "value",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"key"
]
}
},
{
"tableName": "string_preference",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))",
"fields": [
{
"fieldPath": "key",
"columnName": "key",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "value",
"columnName": "value",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"key"
]
}
},
{
"tableName": "stat",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`date` TEXT NOT NULL, `focusTimeQ1` INTEGER NOT NULL, `focusTimeQ2` INTEGER NOT NULL, `focusTimeQ3` INTEGER NOT NULL, `focusTimeQ4` INTEGER NOT NULL, `breakTime` INTEGER NOT NULL, PRIMARY KEY(`date`))",
"fields": [
{
"fieldPath": "date",
"columnName": "date",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "focusTimeQ1",
"columnName": "focusTimeQ1",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "focusTimeQ2",
"columnName": "focusTimeQ2",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "focusTimeQ3",
"columnName": "focusTimeQ3",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "focusTimeQ4",
"columnName": "focusTimeQ4",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "breakTime",
"columnName": "breakTime",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"date"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4691d636a1c16c8cd33dc1bf0602190c')"
]
}
}

View File

@@ -6,6 +6,7 @@
<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" />
<uses-permission android:name="android.permission.VIBRATE" />
<application
android:name=".TomatoApplication"

View File

@@ -6,6 +6,7 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.runtime.LaunchedEffect
import org.nsh07.pomodoro.ui.AppScreen
import org.nsh07.pomodoro.ui.NavItem
import org.nsh07.pomodoro.ui.Screen
@@ -18,23 +19,44 @@ class MainActivity : ComponentActivity() {
private val timerViewModel: TimerViewModel by viewModels(factoryProducer = { TimerViewModel.Factory })
private val statsViewModel: StatsViewModel by viewModels(factoryProducer = { StatsViewModel.Factory })
private val appContainer by lazy {
(application as TomatoApplication).container
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
TomatoTheme {
val colorScheme = colorScheme
LaunchedEffect(colorScheme) {
appContainer.appTimerRepository.colorScheme = colorScheme
}
timerViewModel.setCompositionLocals(colorScheme)
AppScreen(timerViewModel = timerViewModel, statsViewModel = statsViewModel)
}
}
}
override fun onStop() {
super.onStop()
// Reduce the timer loop frequency when not visible to save battery power
appContainer.appTimerRepository.timerFrequency = 1f
}
override fun onStart() {
super.onStart()
// Increase the timer loop frequency again when visible to make the progress smoother
appContainer.appTimerRepository.timerFrequency = 10f
}
companion object {
val screens = listOf(
NavItem(
Screen.Timer,
R.drawable.hourglass,
R.drawable.hourglass_filled,
R.drawable.timer_outlined,
R.drawable.timer_filled,
"Timer"
),
NavItem(

View File

@@ -8,14 +8,18 @@
package org.nsh07.pomodoro.data
import android.content.Context
import androidx.room.AutoMigration
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
@Database(
entities = [IntPreference::class, Stat::class],
version = 1
entities = [IntPreference::class, BooleanPreference::class, StringPreference::class, Stat::class],
version = 2,
autoMigrations = [
AutoMigration(from = 1, to = 2)
]
)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {

View File

@@ -11,7 +11,17 @@ import androidx.room.Entity
import androidx.room.PrimaryKey
/**
* Class for storing app preferences (settings) in the app's database
* Class for storing boolean preferences in the app's database
*/
@Entity(tableName = "boolean_preference")
data class BooleanPreference(
@PrimaryKey
val key: String,
val value: Boolean
)
/**
* Class for storing integer preferences in the app's database
*/
@Entity(tableName = "int_preference")
data class IntPreference(
@@ -19,3 +29,13 @@ data class IntPreference(
val key: String,
val value: Int
)
/**
* Class for storing string preferences in the app's database
*/
@Entity(tableName = "string_preference")
data class StringPreference(
@PrimaryKey
val key: String,
val value: String
)

View File

@@ -11,15 +11,40 @@ import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy.Companion.REPLACE
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface PreferenceDao {
@Insert(onConflict = REPLACE)
suspend fun insertIntPreference(preference: IntPreference)
@Insert(onConflict = REPLACE)
suspend fun insertBooleanPreference(preference: BooleanPreference)
@Insert(onConflict = REPLACE)
suspend fun insertStringPreference(preference: StringPreference)
@Query("DELETE FROM int_preference")
suspend fun resetIntPreferences()
@Query("DELETE FROM boolean_preference")
suspend fun resetBooleanPreferences()
@Query("DELETE FROM string_preference")
suspend fun resetStringPreferences()
@Query("SELECT value FROM int_preference WHERE `key` = :key")
suspend fun getIntPreference(key: String): Int?
@Query("SELECT value FROM boolean_preference WHERE `key` = :key")
suspend fun getBooleanPreference(key: String): Boolean?
@Query("SELECT value FROM boolean_preference WHERE `key` = :key")
fun getBooleanPreferenceFlow(key: String): Flow<Boolean>
@Query("SELECT value FROM string_preference WHERE `key` = :key")
suspend fun getStringPreference(key: String): String?
@Query("SELECT value FROM string_preference WHERE `key` = :key")
fun getStringPreferenceFlow(key: String): Flow<String>
}

View File

@@ -9,12 +9,13 @@ package org.nsh07.pomodoro.data
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
/**
* Interface for reading/writing app preferences to the app's database. This style of storage aims
* to mimic the Preferences DataStore library, preventing the requirement of migration if the
* database schema changes.
* to mimic the Preferences DataStore library, preventing the requirement of migration if new
* preferences are added
*/
interface PreferenceRepository {
/**
@@ -22,11 +23,41 @@ interface PreferenceRepository {
*/
suspend fun saveIntPreference(key: String, value: Int): Int
/**
* Saves a boolean preference key-value pair to the database.
*/
suspend fun saveBooleanPreference(key: String, value: Boolean): Boolean
/**
* Saves a string preference key-value pair to the database.
*/
suspend fun saveStringPreference(key: String, value: String): String
/**
* Retrieves an integer preference key-value pair from the database.
*/
suspend fun getIntPreference(key: String): Int?
/**
* Retrieves a boolean preference key-value pair from the database.
*/
suspend fun getBooleanPreference(key: String): Boolean?
/**
* Retrieves a boolean preference key-value pair as a flow from the database.
*/
fun getBooleanPreferenceFlow(key: String): Flow<Boolean>
/**
* Retrieves a string preference key-value pair from the database.
*/
suspend fun getStringPreference(key: String): String?
/**
* Retrieves a string preference key-value pair as a flow from the database.
*/
fun getStringPreferenceFlow(key: String): Flow<String>
/**
* Erases all integer preference key-value pairs in the database. Do note that the default values
* will need to be rewritten manually
@@ -47,11 +78,39 @@ class AppPreferenceRepository(
value
}
override suspend fun saveBooleanPreference(key: String, value: Boolean): Boolean =
withContext(ioDispatcher) {
preferenceDao.insertBooleanPreference(BooleanPreference(key, value))
value
}
override suspend fun saveStringPreference(key: String, value: String): String =
withContext(ioDispatcher) {
preferenceDao.insertStringPreference(StringPreference(key, value))
value
}
override suspend fun getIntPreference(key: String): Int? = withContext(ioDispatcher) {
preferenceDao.getIntPreference(key)
}
override suspend fun getBooleanPreference(key: String): Boolean? = withContext(ioDispatcher) {
preferenceDao.getBooleanPreference(key)
}
override fun getBooleanPreferenceFlow(key: String): Flow<Boolean> =
preferenceDao.getBooleanPreferenceFlow(key)
override suspend fun getStringPreference(key: String): String? = withContext(ioDispatcher) {
preferenceDao.getStringPreference(key)
}
override fun getStringPreferenceFlow(key: String): Flow<String> =
preferenceDao.getStringPreferenceFlow(key)
override suspend fun resetSettings() = withContext(ioDispatcher) {
preferenceDao.resetIntPreferences()
preferenceDao.resetBooleanPreferences()
preferenceDao.resetStringPreferences()
}
}

View File

@@ -7,6 +7,11 @@
package org.nsh07.pomodoro.data
import android.net.Uri
import android.provider.Settings
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.lightColorScheme
/**
* Interface that holds the timer durations for each timer type. This repository maintains a single
* source of truth for the timer durations for the various ViewModels in the app.
@@ -15,7 +20,17 @@ interface TimerRepository {
var focusTime: Long
var shortBreakTime: Long
var longBreakTime: Long
var sessionLength: Int
var timerFrequency: Float
var alarmEnabled: Boolean
var vibrateEnabled: Boolean
var colorScheme: ColorScheme
var alarmSoundUri: Uri?
}
/**
@@ -26,4 +41,10 @@ class AppTimerRepository : TimerRepository {
override var shortBreakTime = 5 * 60 * 1000L
override var longBreakTime = 15 * 60 * 1000L
override var sessionLength = 4
override var timerFrequency: Float = 10f
override var alarmEnabled = true
override var vibrateEnabled = true
override var colorScheme = lightColorScheme()
override var alarmSoundUri: Uri? =
Settings.System.DEFAULT_ALARM_ALERT_URI ?: Settings.System.DEFAULT_RINGTONE_URI
}

View File

@@ -7,9 +7,9 @@ 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 android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import androidx.compose.ui.graphics.toArgb
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
@@ -17,41 +17,35 @@ 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()
private val appContainer by lazy {
(application as TomatoApplication).container
}
var time: Long
private val timerRepository by lazy { appContainer.appTimerRepository }
private val statRepository by lazy { appContainer.appStatRepository }
private val notificationManager by lazy { NotificationManagerCompat.from(this) }
private val notificationBuilder by lazy { appContainer.notificationBuilder }
private val _timerState by lazy { appContainer.timerState }
private val _time by lazy { appContainer.time }
private val timeStateFlow by lazy { _time.asStateFlow() }
private var time: Long
get() = timeStateFlow.value
set(value) = _time.update { value }
lateinit var timerState: StateFlow<TimerState>
private val timerState by lazy { _timerState.asStateFlow() }
private var cycles = 0
private var startTime = 0L
@@ -62,29 +56,26 @@ class TimerService : Service() {
private val scope = CoroutineScope(Dispatchers.IO + job)
private val skipScope = CoroutineScope(Dispatchers.IO + job)
private lateinit var alarm: MediaPlayer
private var alarm: MediaPlayer? = null
private var cs: ColorScheme = lightColorScheme()
private val vibrator by lazy {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val vibratorManager = getSystemService(VIBRATOR_MANAGER_SERVICE) as VibratorManager
vibratorManager.defaultVibrator
} else {
@Suppress("DEPRECATION") getSystemService(VIBRATOR_SERVICE) as Vibrator
}
}
private val cs by lazy { timerRepository.colorScheme }
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
)
super.onCreate()
alarm = MediaPlayer.create(this, timerRepository.alarmSoundUri)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -103,32 +94,26 @@ class TimerService : Service() {
Actions.SKIP.toString() -> skipTimer(true)
Actions.STOP_ALARM.toString() -> stopAlarm()
Actions.UPDATE_ALARM_TONE.toString() -> updateAlarmTone()
}
return super.onStartCommand(intent, flags, startId)
}
private fun toggleTimer() {
if (timerState.value.timerRunning) {
notificationBuilder
.clearActions()
.addTimerActions(
this,
R.drawable.play,
"Start"
)
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"
)
notificationBuilder.clearActions().addTimerActions(
this, R.drawable.pause, "Stop"
)
_timerState.update { it.copy(timerRunning = true) }
if (pauseTime != 0L) pauseDuration += SystemClock.elapsedRealtime() - pauseTime
@@ -147,7 +132,8 @@ class TimerService : Service() {
else -> timerRepository.longBreakTime - (SystemClock.elapsedRealtime() - startTime - pauseDuration).toInt()
}
iterations = (iterations + 1) % 10
iterations =
(iterations + 1) % timerRepository.timerFrequency.toInt().coerceAtLeast(1)
if (iterations == 0) showTimerNotification(time.toInt())
@@ -165,7 +151,7 @@ class TimerService : Service() {
}
}
delay(100)
delay((1000f / timerRepository.timerFrequency).toLong())
}
}
}
@@ -208,40 +194,42 @@ class TimerService : Service() {
)
.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()
.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(
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())
when (timerState.value.timerMode) {
TimerMode.FOCUS -> timerRepository.focusTime.toInt()
TimerMode.SHORT_BREAK -> timerRepository.shortBreakTime.toInt()
else -> timerRepository.longBreakTime.toInt()
}
)
)
}
} 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()
@@ -254,7 +242,7 @@ class TimerService : Service() {
)
if (complete) {
alarm.start()
startAlarm()
_timerState.update { currentState ->
currentState.copy(alarmRinging = true)
}
@@ -328,9 +316,30 @@ class TimerService : Service() {
}
}
fun startAlarm() {
if (timerRepository.alarmEnabled) alarm?.start()
if (timerRepository.vibrateEnabled) {
if (!vibrator.hasVibrator()) {
return
}
val vibrationPattern = longArrayOf(0, 1000, 1000, 1000)
val repeat = 2
val effect = VibrationEffect.createWaveform(vibrationPattern, repeat)
vibrator.vibrate(effect)
}
}
fun stopAlarm() {
alarm.pause()
alarm.seekTo(0)
if (timerRepository.alarmEnabled) {
alarm?.pause()
alarm?.seekTo(0)
}
if (timerRepository.vibrateEnabled) {
vibrator.cancel()
}
_timerState.update { currentState ->
currentState.copy(alarmRinging = false)
}
@@ -340,12 +349,15 @@ class TimerService : Service() {
TimerMode.FOCUS -> timerRepository.focusTime.toInt()
TimerMode.SHORT_BREAK -> timerRepository.shortBreakTime.toInt()
else -> timerRepository.longBreakTime.toInt()
},
paused = true,
complete = false
}, paused = true, complete = false
)
}
fun updateAlarmTone() {
alarm?.release()
alarm = MediaPlayer.create(this, timerRepository.alarmSoundUri)
}
suspend fun saveTimeToDb() {
when (timerState.value.timerMode) {
TimerMode.FOCUS -> statRepository.addFocusTime(
@@ -374,10 +386,11 @@ class TimerService : Service() {
job.cancel()
saveTimeToDb()
notificationManager.cancel(1)
alarm?.release()
}
}
enum class Actions {
TOGGLE, SKIP, RESET, STOP_ALARM
TOGGLE, SKIP, RESET, STOP_ALARM, UPDATE_ALARM_TONE
}
}

View File

@@ -28,14 +28,11 @@ import androidx.compose.material3.ShortNavigationBarItem
import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
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
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -50,6 +47,7 @@ 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.AlarmDialog
import org.nsh07.pomodoro.ui.timerScreen.TimerScreen
import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerAction
import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerViewModel
@@ -69,16 +67,19 @@ fun AppScreen(
val progress by rememberUpdatedState((uiState.totalTime.toFloat() - remainingTime) / uiState.totalTime)
val layoutDirection = LocalLayoutDirection.current
val haptic = LocalHapticFeedback.current
val motionScheme = motionScheme
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
LaunchedEffect(uiState.timerMode) {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
}
val backStack = rememberNavBackStack<Screen>(Screen.Timer)
if (uiState.alarmRinging)
AlarmDialog {
Intent(context, TimerService::class.java).also {
it.action = TimerService.Actions.STOP_ALARM.toString()
context.startService(it)
}
}
Scaffold(
bottomBar = {
val wide = remember {

View File

@@ -7,8 +7,17 @@
package org.nsh07.pomodoro.ui.settingsScreen
import android.app.Activity
import android.content.Intent
import android.media.RingtoneManager
import android.net.Uri
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.DrawableRes
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -18,8 +27,10 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.input.TextFieldState
@@ -32,10 +43,11 @@ import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.ListItem
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.material3.MaterialTheme.shapes
import androidx.compose.material3.MaterialTheme.typography
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderState
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
@@ -50,18 +62,25 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import org.nsh07.pomodoro.R
import org.nsh07.pomodoro.service.TimerService
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.TomatoShapeDefaults.bottomListItemShape
import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.middleListItemShape
import org.nsh07.pomodoro.ui.theme.TomatoShapeDefaults.topListItemShape
import org.nsh07.pomodoro.ui.theme.TomatoTheme
@OptIn(ExperimentalMaterial3Api::class)
@@ -70,6 +89,7 @@ fun SettingsScreenRoot(
modifier: Modifier = Modifier,
viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.Factory)
) {
val context = LocalContext.current
val focusTimeInputFieldState = rememberSaveable(saver = TextFieldState.Saver) {
viewModel.focusTimeTextFieldState
}
@@ -80,6 +100,10 @@ fun SettingsScreenRoot(
viewModel.longBreakTimeTextFieldState
}
val alarmEnabled by viewModel.alarmEnabled.collectAsStateWithLifecycle(true)
val vibrateEnabled by viewModel.vibrateEnabled.collectAsStateWithLifecycle(true)
val alarmSound by viewModel.alarmSound.collectAsStateWithLifecycle(viewModel.currentAlarmSound)
val sessionsSliderState = rememberSaveable(
saver = SliderState.Saver(
viewModel.sessionsSliderState.onValueChangeFinished,
@@ -94,6 +118,18 @@ fun SettingsScreenRoot(
shortBreakTimeInputFieldState = shortBreakTimeInputFieldState,
longBreakTimeInputFieldState = longBreakTimeInputFieldState,
sessionsSliderState = sessionsSliderState,
alarmEnabled = alarmEnabled,
vibrateEnabled = vibrateEnabled,
alarmSound = alarmSound,
onAlarmEnabledChange = viewModel::saveAlarmEnabled,
onVibrateEnabledChange = viewModel::saveVibrateEnabled,
onAlarmSoundChanged = {
viewModel.saveAlarmSound(it)
Intent(context, TimerService::class.java).apply {
action = TimerService.Actions.RESET.toString()
context.startService(this)
}
},
modifier = modifier
)
}
@@ -105,9 +141,63 @@ private fun SettingsScreen(
shortBreakTimeInputFieldState: TextFieldState,
longBreakTimeInputFieldState: TextFieldState,
sessionsSliderState: SliderState,
alarmEnabled: Boolean,
vibrateEnabled: Boolean,
alarmSound: String,
onAlarmEnabledChange: (Boolean) -> Unit,
onVibrateEnabledChange: (Boolean) -> Unit,
onAlarmSoundChanged: (Uri?) -> Unit,
modifier: Modifier = Modifier
) {
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
val switchColors = SwitchDefaults.colors(
checkedIconColor = colorScheme.primary,
)
val context = LocalContext.current
val ringtonePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
val uri =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
result.data?.getParcelableExtra(
RingtoneManager.EXTRA_RINGTONE_PICKED_URI,
Uri::class.java
)
} else {
@Suppress("DEPRECATION")
result.data?.getParcelableExtra(RingtoneManager.EXTRA_RINGTONE_PICKED_URI)
}
onAlarmSoundChanged(uri)
}
}
val intent = Intent(RingtoneManager.ACTION_RINGTONE_PICKER).apply {
putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, RingtoneManager.TYPE_ALARM)
putExtra(RingtoneManager.EXTRA_RINGTONE_TITLE, "Alarm sound")
putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, alarmSound.toUri())
}
val switchItems = remember(alarmEnabled, vibrateEnabled) {
listOf(
SettingsSwitchItem(
checked = alarmEnabled,
icon = R.drawable.alarm_on,
label = "Alarm",
description = "Ring alarm when a timer completes",
onClick = onAlarmEnabledChange
),
SettingsSwitchItem(
checked = vibrateEnabled,
icon = R.drawable.mobile_vibrate,
label = "Vibrate",
description = "Vibrate when a timer completes",
onClick = onVibrateEnabledChange
)
)
}
Column(modifier.nestedScroll(scrollBehavior.nestedScrollConnection)) {
TopAppBar(
@@ -155,10 +245,10 @@ private fun SettingsScreen(
MinuteInputField(
state = focusTimeInputFieldState,
shape = RoundedCornerShape(
topStart = 16.dp,
bottomStart = 16.dp,
topEnd = 4.dp,
bottomEnd = 4.dp
topStart = topListItemShape.topStart,
bottomStart = topListItemShape.topStart,
topEnd = topListItemShape.bottomStart,
bottomEnd = topListItemShape.bottomStart
),
imeAction = ImeAction.Next
)
@@ -174,7 +264,7 @@ private fun SettingsScreen(
)
MinuteInputField(
state = shortBreakTimeInputFieldState,
shape = RoundedCornerShape(4.dp),
shape = RoundedCornerShape(middleListItemShape.topStart),
imeAction = ImeAction.Next
)
}
@@ -190,10 +280,10 @@ private fun SettingsScreen(
MinuteInputField(
state = longBreakTimeInputFieldState,
shape = RoundedCornerShape(
topStart = 4.dp,
bottomStart = 4.dp,
topEnd = 16.dp,
bottomEnd = 16.dp
topStart = bottomListItemShape.topStart,
bottomStart = bottomListItemShape.topStart,
topEnd = bottomListItemShape.bottomStart,
bottomEnd = bottomListItemShape.bottomStart
),
imeAction = ImeAction.Done
)
@@ -224,7 +314,68 @@ private fun SettingsScreen(
}
},
colors = listItemColors,
modifier = Modifier.clip(shapes.large)
modifier = Modifier.clip(topListItemShape)
)
}
item {
ListItem(
leadingContent = {
Icon(painterResource(R.drawable.alarm), null)
},
headlineContent = { Text("Alarm sound") },
supportingContent = {
Text(
remember(alarmSound) {
RingtoneManager.getRingtone(context, alarmSound.toUri())
.getTitle(context)
}
)
},
colors = listItemColors,
modifier = Modifier
.clip(bottomListItemShape)
.clickable(onClick = { ringtonePickerLauncher.launch(intent) })
)
}
item { Spacer(Modifier.height(12.dp)) }
itemsIndexed(switchItems) { index, item ->
ListItem(
leadingContent = {
Icon(painterResource(item.icon), contentDescription = null)
},
headlineContent = { Text(item.label) },
supportingContent = { Text(item.description) },
trailingContent = {
Switch(
checked = item.checked,
onCheckedChange = { item.onClick(it) },
thumbContent = {
if (item.checked) {
Icon(
painter = painterResource(R.drawable.check),
contentDescription = null,
modifier = Modifier.size(SwitchDefaults.IconSize),
)
} else {
Icon(
painter = painterResource(R.drawable.clear),
contentDescription = null,
modifier = Modifier.size(SwitchDefaults.IconSize),
)
}
},
colors = switchColors
)
},
colors = listItemColors,
modifier = Modifier
.clip(
when (index) {
0 -> topListItemShape
switchItems.lastIndex -> bottomListItemShape
else -> middleListItemShape
}
)
)
}
item {
@@ -275,7 +426,21 @@ fun SettingsScreenPreview() {
shortBreakTimeInputFieldState = rememberTextFieldState((5 * 60 * 1000).toString()),
longBreakTimeInputFieldState = rememberTextFieldState((15 * 60 * 1000).toString()),
sessionsSliderState = rememberSliderState(value = 3f, steps = 3, valueRange = 1f..5f),
alarmEnabled = true,
vibrateEnabled = true,
alarmSound = "null",
onAlarmEnabledChange = {},
onVibrateEnabledChange = {},
onAlarmSoundChanged = {},
modifier = Modifier.fillMaxSize()
)
}
}
data class SettingsSwitchItem(
val checked: Boolean,
@DrawableRes val icon: Int,
val label: String,
val description: String,
val onClick: (Boolean) -> Unit
)

View File

@@ -7,6 +7,7 @@
package org.nsh07.pomodoro.ui.settingsScreen.viewModel
import android.net.Uri
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SliderState
@@ -20,6 +21,7 @@ import androidx.lifecycle.viewmodel.viewModelFactory
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import org.nsh07.pomodoro.TomatoApplication
import org.nsh07.pomodoro.data.AppPreferenceRepository
@@ -44,6 +46,15 @@ class SettingsViewModel(
onValueChangeFinished = ::updateSessionLength
)
val currentAlarmSound = timerRepository.alarmSoundUri.toString()
val alarmSound =
preferenceRepository.getStringPreferenceFlow("alarm_sound").distinctUntilChanged()
val alarmEnabled =
preferenceRepository.getBooleanPreferenceFlow("alarm_enabled").distinctUntilChanged()
val vibrateEnabled =
preferenceRepository.getBooleanPreferenceFlow("vibrate_enabled").distinctUntilChanged()
init {
viewModelScope.launch(Dispatchers.IO) {
snapshotFlow { focusTimeTextFieldState.text }
@@ -92,6 +103,27 @@ class SettingsViewModel(
}
}
fun saveAlarmEnabled(enabled: Boolean) {
viewModelScope.launch {
preferenceRepository.saveBooleanPreference("alarm_enabled", enabled)
timerRepository.alarmEnabled = enabled
}
}
fun saveVibrateEnabled(enabled: Boolean) {
viewModelScope.launch {
preferenceRepository.saveBooleanPreference("vibrate_enabled", enabled)
timerRepository.vibrateEnabled = enabled
}
}
fun saveAlarmSound(uri: Uri?) {
viewModelScope.launch {
preferenceRepository.saveStringPreference("alarm_sound", uri.toString())
}
timerRepository.alarmSoundUri = uri
}
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {

View File

@@ -0,0 +1,43 @@
/*
* 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.theme
import androidx.compose.foundation.shape.CornerBasedShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme.shapes
import androidx.compose.runtime.Composable
object TomatoShapeDefaults {
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
val topListItemShape: RoundedCornerShape
@Composable get() =
RoundedCornerShape(
topStart = shapes.largeIncreased.topStart,
topEnd = shapes.largeIncreased.topEnd,
bottomStart = shapes.extraSmall.bottomStart,
bottomEnd = shapes.extraSmall.bottomStart
)
val middleListItemShape: RoundedCornerShape
@Composable get() = RoundedCornerShape(shapes.extraSmall.topStart)
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
val bottomListItemShape: RoundedCornerShape
@Composable get() =
RoundedCornerShape(
topStart = shapes.extraSmall.topStart,
topEnd = shapes.extraSmall.topEnd,
bottomStart = shapes.largeIncreased.bottomStart,
bottomEnd = shapes.largeIncreased.bottomEnd
)
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
val cardShape: CornerBasedShape
@Composable get() = shapes.largeIncreased
}

View File

@@ -69,7 +69,7 @@ fun AlarmDialog(
onClick = stopAlarm,
modifier = Modifier.align(Alignment.End),
) {
Text("Ok")
Text("Dismiss")
}
}
}

View File

@@ -63,7 +63,9 @@ 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.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
@@ -89,6 +91,7 @@ fun TimerScreen(
modifier: Modifier = Modifier
) {
val motionScheme = motionScheme
val haptic = LocalHapticFeedback.current
val color by animateColorAsState(
if (timerState.timerMode == TimerMode.FOCUS) colorScheme.primary
@@ -111,9 +114,6 @@ fun TimerScreen(
onResult = {}
)
if (timerState.alarmRinging)
AlarmDialog { onAction(TimerAction.StopAlarm) }
Column(modifier = modifier) {
TopAppBar(
title = {
@@ -301,6 +301,10 @@ fun TimerScreen(
FilledIconToggleButton(
onCheckedChange = { checked ->
onAction(TimerAction.ToggleTimer)
if (checked) haptic.performHapticFeedback(HapticFeedbackType.ToggleOn)
else haptic.performHapticFeedback(HapticFeedbackType.ToggleOff)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && checked) {
permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
@@ -358,7 +362,10 @@ fun TimerScreen(
customItem(
{
FilledTonalIconButton(
onClick = { onAction(TimerAction.ResetTimer) },
onClick = {
onAction(TimerAction.ResetTimer)
haptic.performHapticFeedback(HapticFeedbackType.VirtualKey)
},
colors = IconButtonDefaults.filledTonalIconButtonColors(
containerColor = colorContainer
),
@@ -395,7 +402,10 @@ fun TimerScreen(
customItem(
{
FilledTonalIconButton(
onClick = { onAction(TimerAction.SkipTimer(fromButton = true)) },
onClick = {
onAction(TimerAction.SkipTimer(fromButton = true))
haptic.performHapticFeedback(HapticFeedbackType.VirtualKey)
},
colors = IconButtonDefaults.filledTonalIconButtonColors(
containerColor = colorContainer
),

View File

@@ -8,7 +8,9 @@
package org.nsh07.pomodoro.ui.timerScreen.viewModel
import android.app.Application
import android.provider.Settings
import androidx.compose.material3.ColorScheme
import androidx.core.net.toUri
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
@@ -30,6 +32,7 @@ import org.nsh07.pomodoro.data.StatRepository
import org.nsh07.pomodoro.data.TimerRepository
import org.nsh07.pomodoro.utils.millisecondsToStr
import java.time.LocalDate
import java.time.temporal.ChronoUnit
@OptIn(FlowPreview::class)
class TimerViewModel(
@@ -77,16 +80,33 @@ class TimerViewModel(
timerRepository.sessionLength
)
timerRepository.alarmEnabled =
preferenceRepository.getBooleanPreference("alarm_enabled")
?: preferenceRepository.saveBooleanPreference("alarm_enabled", true)
timerRepository.vibrateEnabled =
preferenceRepository.getBooleanPreference("vibrate_enabled")
?: preferenceRepository.saveBooleanPreference("vibrate_enabled", true)
timerRepository.alarmSoundUri = (
preferenceRepository.getStringPreference("alarm_sound")
?: preferenceRepository.saveStringPreference(
"alarm_sound",
(Settings.System.DEFAULT_ALARM_ALERT_URI
?: Settings.System.DEFAULT_RINGTONE_URI).toString()
)
).toUri()
resetTimer()
var lastDate = statRepository.getLastDate()
val today = LocalDate.now()
// Fills dates between today and lastDate with 0s to ensure continuous history
while ((lastDate?.until(today)?.days ?: -1) > 0) {
lastDate = lastDate?.plusDays(1)
statRepository.insertStat(Stat(lastDate!!, 0, 0, 0, 0, 0))
}
if (lastDate != null)
while (ChronoUnit.DAYS.between(lastDate, today) > 0) {
lastDate = lastDate?.plusDays(1)
statRepository.insertStat(Stat(lastDate!!, 0, 0, 0, 0, 0))
}
delay(1500)

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="m438,548 l-57,-57q-12,-12 -28,-12t-28,12q-12,12 -12,28.5t12,28.5l85,86q12,12 28,12t28,-12l170,-170q12,-12 12,-28.5T636,407q-12,-12 -28.5,-12T579,407L438,548ZM480,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,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="m382,606 l339,-339q12,-12 28,-12t28,12q12,12 12,28.5T777,324L410,692q-12,12 -28,12t-28,-12L182,520q-12,-12 -11.5,-28.5T183,463q12,-12 28.5,-12t28.5,12l142,143Z" />
</vector>

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="M480,536 L284,732q-11,11 -28,11t-28,-11q-11,-11 -11,-28t11,-28l196,-196 -196,-196q-11,-11 -11,-28t11,-28q11,-11 28,-11t28,11l196,196 196,-196q11,-11 28,-11t28,11q11,11 11,28t-11,28L536,480l196,196q11,11 11,28t-11,28q-11,11 -28,11t-28,-11L480,536Z" />
</vector>

View File

@@ -1,10 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="#000000"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M320,800h320v-120q0,-66 -47,-113t-113,-47q-66,0 -113,47t-47,113v120ZM480,440q66,0 113,-47t47,-113v-120L320,160v120q0,66 47,113t113,47ZM200,880q-17,0 -28.5,-11.5T160,840q0,-17 11.5,-28.5T200,800h40v-120q0,-61 28.5,-114.5T348,480q-51,-32 -79.5,-85.5T240,280v-120h-40q-17,0 -28.5,-11.5T160,120q0,-17 11.5,-28.5T200,80h560q17,0 28.5,11.5T800,120q0,17 -11.5,28.5T760,160h-40v120q0,61 -28.5,114.5T612,480q51,32 79.5,85.5T720,680v120h40q17,0 28.5,11.5T800,840q0,17 -11.5,28.5T760,880L200,880ZM480,800ZM480,160Z" />
</vector>

View File

@@ -1,10 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="#000000"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M200,880q-17,0 -28.5,-11.5T160,840q0,-17 11.5,-28.5T200,800h40v-120q0,-61 28.5,-114.5T348,480q-51,-32 -79.5,-85.5T240,280v-120h-40q-17,0 -28.5,-11.5T160,120q0,-17 11.5,-28.5T200,80h560q17,0 28.5,11.5T800,120q0,17 -11.5,28.5T760,160h-40v120q0,61 -28.5,114.5T612,480q51,32 79.5,85.5T720,680v120h40q17,0 28.5,11.5T800,840q0,17 -11.5,28.5T760,880L200,880Z" />
</vector>

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="M320,840q-33,0 -56.5,-23.5T240,760v-560q0,-33 23.5,-56.5T320,120h320q33,0 56.5,23.5T720,200v560q0,33 -23.5,56.5T640,840L320,840ZM480,320q17,0 28.5,-11.5T520,280q0,-17 -11.5,-28.5T480,240q-17,0 -28.5,11.5T440,280q0,17 11.5,28.5T480,320ZM0,560v-160q0,-17 11.5,-28.5T40,360q17,0 28.5,11.5T80,400v160q0,17 -11.5,28.5T40,600q-17,0 -28.5,-11.5T0,560ZM120,640v-320q0,-17 11.5,-28.5T160,280q17,0 28.5,11.5T200,320v320q0,17 -11.5,28.5T160,680q-17,0 -28.5,-11.5T120,640ZM880,560v-160q0,-17 11.5,-28.5T920,360q17,0 28.5,11.5T960,400v160q0,17 -11.5,28.5T920,600q-17,0 -28.5,-11.5T880,560ZM760,640v-320q0,-17 11.5,-28.5T800,280q17,0 28.5,11.5T840,320v320q0,17 -11.5,28.5T800,680q-17,0 -28.5,-11.5T760,640Z" />
</vector>

View File

@@ -0,0 +1,9 @@
<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="M400,120q-17,0 -28.5,-11.5T360,80q0,-17 11.5,-28.5T400,40h160q17,0 28.5,11.5T600,80q0,17 -11.5,28.5T560,120L400,120ZM480,560q17,0 28.5,-11.5T520,520v-160q0,-17 -11.5,-28.5T480,320q-17,0 -28.5,11.5T440,360v160q0,17 11.5,28.5T480,560ZM480,880q-74,0 -139.5,-28.5T226,774q-49,-49 -77.5,-114.5T120,520q0,-74 28.5,-139.5T226,266q49,-49 114.5,-77.5T480,160q62,0 119,20t107,58l28,-28q11,-11 28,-11t28,11q11,11 11,28t-11,28l-28,28q38,50 58,107t20,119q0,74 -28.5,139.5T734,774q-49,49 -114.5,77.5T480,880Z" />
</vector>

View File

@@ -0,0 +1,9 @@
<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="M400,120q-17,0 -28.5,-11.5T360,80q0,-17 11.5,-28.5T400,40h160q17,0 28.5,11.5T600,80q0,17 -11.5,28.5T560,120L400,120ZM480,560q17,0 28.5,-11.5T520,520v-160q0,-17 -11.5,-28.5T480,320q-17,0 -28.5,11.5T440,360v160q0,17 11.5,28.5T480,560ZM480,880q-74,0 -139.5,-28.5T226,774q-49,-49 -77.5,-114.5T120,520q0,-74 28.5,-139.5T226,266q49,-49 114.5,-77.5T480,160q62,0 119,20t107,58l28,-28q11,-11 28,-11t28,11q11,11 11,28t-11,28l-28,28q38,50 58,107t20,119q0,74 -28.5,139.5T734,774q-49,49 -114.5,77.5T480,880ZM480,800q116,0 198,-82t82,-198q0,-116 -82,-198t-198,-82q-116,0 -198,82t-82,198q0,116 82,198t198,82ZM480,520Z" />
</vector>

View File

@@ -0,0 +1,9 @@
New features:
- New custom alarm ringtone option
- Vibration when a timer completes
- New options in settings to disable alarm and vibration
Fixes:
- Alarm dialog not visible when on the stats or settings screen
- Improved timer stability
- System M3 colors not being used in Live Update/Now Bar progress bar

Binary file not shown.

Before

Width:  |  Height:  |  Size: 322 KiB

After

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 316 KiB

After

Width:  |  Height:  |  Size: 323 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 329 KiB

After

Width:  |  Height:  |  Size: 330 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 352 KiB

After

Width:  |  Height:  |  Size: 353 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 372 KiB

After

Width:  |  Height:  |  Size: 385 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 303 KiB

After

Width:  |  Height:  |  Size: 348 KiB