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" applicationId = "org.nsh07.pomodoro"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 6 versionCode = 7
versionName = "1.2.0" versionName = "1.3.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 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.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.POST_PROMOTED_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_PROMOTED_NOTIFICATIONS" />
<uses-permission android:name="android.permission.VIBRATE" />
<application <application
android:name=".TomatoApplication" android:name=".TomatoApplication"

View File

@@ -6,6 +6,7 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.runtime.LaunchedEffect
import org.nsh07.pomodoro.ui.AppScreen import org.nsh07.pomodoro.ui.AppScreen
import org.nsh07.pomodoro.ui.NavItem import org.nsh07.pomodoro.ui.NavItem
import org.nsh07.pomodoro.ui.Screen import org.nsh07.pomodoro.ui.Screen
@@ -18,23 +19,44 @@ class MainActivity : ComponentActivity() {
private val timerViewModel: TimerViewModel by viewModels(factoryProducer = { TimerViewModel.Factory }) private val timerViewModel: TimerViewModel by viewModels(factoryProducer = { TimerViewModel.Factory })
private val statsViewModel: StatsViewModel by viewModels(factoryProducer = { StatsViewModel.Factory }) private val statsViewModel: StatsViewModel by viewModels(factoryProducer = { StatsViewModel.Factory })
private val appContainer by lazy {
(application as TomatoApplication).container
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
TomatoTheme { TomatoTheme {
val colorScheme = colorScheme
LaunchedEffect(colorScheme) {
appContainer.appTimerRepository.colorScheme = colorScheme
}
timerViewModel.setCompositionLocals(colorScheme) timerViewModel.setCompositionLocals(colorScheme)
AppScreen(timerViewModel = timerViewModel, statsViewModel = statsViewModel) 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 { companion object {
val screens = listOf( val screens = listOf(
NavItem( NavItem(
Screen.Timer, Screen.Timer,
R.drawable.hourglass, R.drawable.timer_outlined,
R.drawable.hourglass_filled, R.drawable.timer_filled,
"Timer" "Timer"
), ),
NavItem( NavItem(

View File

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

View File

@@ -11,7 +11,17 @@ import androidx.room.Entity
import androidx.room.PrimaryKey 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") @Entity(tableName = "int_preference")
data class IntPreference( data class IntPreference(
@@ -19,3 +29,13 @@ data class IntPreference(
val key: String, val key: String,
val value: Int 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.Insert
import androidx.room.OnConflictStrategy.Companion.REPLACE import androidx.room.OnConflictStrategy.Companion.REPLACE
import androidx.room.Query import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao @Dao
interface PreferenceDao { interface PreferenceDao {
@Insert(onConflict = REPLACE) @Insert(onConflict = REPLACE)
suspend fun insertIntPreference(preference: IntPreference) 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") @Query("DELETE FROM int_preference")
suspend fun resetIntPreferences() 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") @Query("SELECT value FROM int_preference WHERE `key` = :key")
suspend fun getIntPreference(key: String): Int? 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.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
/** /**
* Interface for reading/writing app preferences to the app's database. This style of storage aims * 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 * to mimic the Preferences DataStore library, preventing the requirement of migration if new
* database schema changes. * preferences are added
*/ */
interface PreferenceRepository { interface PreferenceRepository {
/** /**
@@ -22,11 +23,41 @@ interface PreferenceRepository {
*/ */
suspend fun saveIntPreference(key: String, value: Int): Int 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. * Retrieves an integer preference key-value pair from the database.
*/ */
suspend fun getIntPreference(key: String): Int? 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 * Erases all integer preference key-value pairs in the database. Do note that the default values
* will need to be rewritten manually * will need to be rewritten manually
@@ -47,11 +78,39 @@ class AppPreferenceRepository(
value 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) { override suspend fun getIntPreference(key: String): Int? = withContext(ioDispatcher) {
preferenceDao.getIntPreference(key) 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) { override suspend fun resetSettings() = withContext(ioDispatcher) {
preferenceDao.resetIntPreferences() preferenceDao.resetIntPreferences()
preferenceDao.resetBooleanPreferences()
preferenceDao.resetStringPreferences()
} }
} }

View File

@@ -7,6 +7,11 @@
package org.nsh07.pomodoro.data 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 * 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. * source of truth for the timer durations for the various ViewModels in the app.
@@ -15,7 +20,17 @@ interface TimerRepository {
var focusTime: Long var focusTime: Long
var shortBreakTime: Long var shortBreakTime: Long
var longBreakTime: Long var longBreakTime: Long
var sessionLength: Int 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 shortBreakTime = 5 * 60 * 1000L
override var longBreakTime = 15 * 60 * 1000L override var longBreakTime = 15 * 60 * 1000L
override var sessionLength = 4 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.Build
import android.os.IBinder import android.os.IBinder
import android.os.SystemClock import android.os.SystemClock
import android.provider.Settings import android.os.VibrationEffect
import androidx.compose.material3.ColorScheme import android.os.Vibrator
import androidx.compose.material3.lightColorScheme import android.os.VibratorManager
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
@@ -17,41 +17,35 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.nsh07.pomodoro.R import org.nsh07.pomodoro.R
import org.nsh07.pomodoro.TomatoApplication 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.TimerMode
import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerState
import org.nsh07.pomodoro.utils.millisecondsToStr import org.nsh07.pomodoro.utils.millisecondsToStr
import kotlin.text.Typography.middleDot import kotlin.text.Typography.middleDot
class TimerService : Service() { class TimerService : Service() {
private lateinit var appContainer: AppContainer private val appContainer by lazy {
(application as TomatoApplication).container
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 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 get() = timeStateFlow.value
set(value) = _time.update { value } set(value) = _time.update { value }
lateinit var timerState: StateFlow<TimerState> private val timerState by lazy { _timerState.asStateFlow() }
private var cycles = 0 private var cycles = 0
private var startTime = 0L private var startTime = 0L
@@ -62,29 +56,26 @@ class TimerService : Service() {
private val scope = CoroutineScope(Dispatchers.IO + job) private val scope = CoroutineScope(Dispatchers.IO + job)
private val skipScope = 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? { override fun onBind(intent: Intent?): IBinder? {
return null return null
} }
override fun onCreate() { override fun onCreate() {
appContainer = (application as TomatoApplication).container super.onCreate()
timerRepository = appContainer.appTimerRepository alarm = MediaPlayer.create(this, timerRepository.alarmSoundUri)
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 { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -103,32 +94,26 @@ class TimerService : Service() {
Actions.SKIP.toString() -> skipTimer(true) Actions.SKIP.toString() -> skipTimer(true)
Actions.STOP_ALARM.toString() -> stopAlarm() Actions.STOP_ALARM.toString() -> stopAlarm()
Actions.UPDATE_ALARM_TONE.toString() -> updateAlarmTone()
} }
return super.onStartCommand(intent, flags, startId) return super.onStartCommand(intent, flags, startId)
} }
private fun toggleTimer() { private fun toggleTimer() {
if (timerState.value.timerRunning) { if (timerState.value.timerRunning) {
notificationBuilder notificationBuilder.clearActions().addTimerActions(
.clearActions() this, R.drawable.play, "Start"
.addTimerActions( )
this,
R.drawable.play,
"Start"
)
showTimerNotification(time.toInt(), paused = true) showTimerNotification(time.toInt(), paused = true)
_timerState.update { currentState -> _timerState.update { currentState ->
currentState.copy(timerRunning = false) currentState.copy(timerRunning = false)
} }
pauseTime = SystemClock.elapsedRealtime() pauseTime = SystemClock.elapsedRealtime()
} else { } else {
notificationBuilder notificationBuilder.clearActions().addTimerActions(
.clearActions() this, R.drawable.pause, "Stop"
.addTimerActions( )
this,
R.drawable.pause,
"Stop"
)
_timerState.update { it.copy(timerRunning = true) } _timerState.update { it.copy(timerRunning = true) }
if (pauseTime != 0L) pauseDuration += SystemClock.elapsedRealtime() - pauseTime if (pauseTime != 0L) pauseDuration += SystemClock.elapsedRealtime() - pauseTime
@@ -147,7 +132,8 @@ class TimerService : Service() {
else -> timerRepository.longBreakTime - (SystemClock.elapsedRealtime() - startTime - pauseDuration).toInt() 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()) 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})") .setContentText("Up next: $nextTimer (${timerState.value.nextTimeStr})")
.setStyle( .setStyle(
NotificationCompat.ProgressStyle().also { NotificationCompat.ProgressStyle()
// Add all the Focus, Short break and long break intervals in order .also {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) { // Add all the Focus, Short break and long break intervals in order
// Android 16 and later supports live updates if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) {
// Set progress bar sections if on Baklava or later // Android 16 and later supports live updates
for (i in 0..<timerRepository.sessionLength * 2) { // Set progress bar sections if on Baklava or later
if (i % 2 == 0) it.addProgressSegment( 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( NotificationCompat.ProgressStyle.Segment(
timerRepository.focusTime.toInt() when (timerState.value.timerMode) {
).setColor(cs.primary.toArgb()) TimerMode.FOCUS -> timerRepository.focusTime.toInt()
) TimerMode.SHORT_BREAK -> timerRepository.shortBreakTime.toInt()
else if (i != (timerRepository.sessionLength * 2 - 1)) it.addProgressSegment( else -> timerRepository.longBreakTime.toInt()
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 .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) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) {
(totalTime - remainingTime) + ((cycles + 1) / 2) * timerRepository.focusTime.toInt() + (cycles / 2) * timerRepository.shortBreakTime.toInt() (totalTime - remainingTime) + ((cycles + 1) / 2) * timerRepository.focusTime.toInt() + (cycles / 2) * timerRepository.shortBreakTime.toInt()
@@ -254,7 +242,7 @@ class TimerService : Service() {
) )
if (complete) { if (complete) {
alarm.start() startAlarm()
_timerState.update { currentState -> _timerState.update { currentState ->
currentState.copy(alarmRinging = true) 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() { fun stopAlarm() {
alarm.pause() if (timerRepository.alarmEnabled) {
alarm.seekTo(0) alarm?.pause()
alarm?.seekTo(0)
}
if (timerRepository.vibrateEnabled) {
vibrator.cancel()
}
_timerState.update { currentState -> _timerState.update { currentState ->
currentState.copy(alarmRinging = false) currentState.copy(alarmRinging = false)
} }
@@ -340,12 +349,15 @@ class TimerService : Service() {
TimerMode.FOCUS -> timerRepository.focusTime.toInt() TimerMode.FOCUS -> timerRepository.focusTime.toInt()
TimerMode.SHORT_BREAK -> timerRepository.shortBreakTime.toInt() TimerMode.SHORT_BREAK -> timerRepository.shortBreakTime.toInt()
else -> timerRepository.longBreakTime.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() { suspend fun saveTimeToDb() {
when (timerState.value.timerMode) { when (timerState.value.timerMode) {
TimerMode.FOCUS -> statRepository.addFocusTime( TimerMode.FOCUS -> statRepository.addFocusTime(
@@ -374,10 +386,11 @@ class TimerService : Service() {
job.cancel() job.cancel()
saveTimeToDb() saveTimeToDb()
notificationManager.cancel(1) notificationManager.cancel(1)
alarm?.release()
} }
} }
enum class Actions { 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.Text
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.settingsScreen.SettingsScreenRoot
import org.nsh07.pomodoro.ui.statsScreen.StatsScreenRoot import org.nsh07.pomodoro.ui.statsScreen.StatsScreenRoot
import org.nsh07.pomodoro.ui.statsScreen.viewModel.StatsViewModel import org.nsh07.pomodoro.ui.statsScreen.viewModel.StatsViewModel
import org.nsh07.pomodoro.ui.timerScreen.AlarmDialog
import org.nsh07.pomodoro.ui.timerScreen.TimerScreen import org.nsh07.pomodoro.ui.timerScreen.TimerScreen
import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerAction import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerAction
import org.nsh07.pomodoro.ui.timerScreen.viewModel.TimerViewModel 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 progress by rememberUpdatedState((uiState.totalTime.toFloat() - remainingTime) / uiState.totalTime)
val layoutDirection = LocalLayoutDirection.current val layoutDirection = LocalLayoutDirection.current
val haptic = LocalHapticFeedback.current
val motionScheme = motionScheme val motionScheme = motionScheme
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
LaunchedEffect(uiState.timerMode) {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
}
val backStack = rememberNavBackStack<Screen>(Screen.Timer) 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( Scaffold(
bottomBar = { bottomBar = {
val wide = remember { val wide = remember {

View File

@@ -7,8 +7,17 @@
package org.nsh07.pomodoro.ui.settingsScreen 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.animation.AnimatedVisibility
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.input.TextFieldState 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.ListItem
import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.material3.MaterialTheme.shapes
import androidx.compose.material3.MaterialTheme.typography import androidx.compose.material3.MaterialTheme.typography
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
import androidx.compose.material3.SliderState import androidx.compose.material3.SliderState
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
@@ -50,18 +62,25 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import org.nsh07.pomodoro.R import org.nsh07.pomodoro.R
import org.nsh07.pomodoro.service.TimerService
import org.nsh07.pomodoro.ui.settingsScreen.viewModel.SettingsViewModel import org.nsh07.pomodoro.ui.settingsScreen.viewModel.SettingsViewModel
import org.nsh07.pomodoro.ui.theme.AppFonts.robotoFlexTopBar import org.nsh07.pomodoro.ui.theme.AppFonts.robotoFlexTopBar
import org.nsh07.pomodoro.ui.theme.CustomColors.listItemColors import org.nsh07.pomodoro.ui.theme.CustomColors.listItemColors
import org.nsh07.pomodoro.ui.theme.CustomColors.topBarColors 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 import org.nsh07.pomodoro.ui.theme.TomatoTheme
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -70,6 +89,7 @@ fun SettingsScreenRoot(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.Factory) viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.Factory)
) { ) {
val context = LocalContext.current
val focusTimeInputFieldState = rememberSaveable(saver = TextFieldState.Saver) { val focusTimeInputFieldState = rememberSaveable(saver = TextFieldState.Saver) {
viewModel.focusTimeTextFieldState viewModel.focusTimeTextFieldState
} }
@@ -80,6 +100,10 @@ fun SettingsScreenRoot(
viewModel.longBreakTimeTextFieldState 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( val sessionsSliderState = rememberSaveable(
saver = SliderState.Saver( saver = SliderState.Saver(
viewModel.sessionsSliderState.onValueChangeFinished, viewModel.sessionsSliderState.onValueChangeFinished,
@@ -94,6 +118,18 @@ fun SettingsScreenRoot(
shortBreakTimeInputFieldState = shortBreakTimeInputFieldState, shortBreakTimeInputFieldState = shortBreakTimeInputFieldState,
longBreakTimeInputFieldState = longBreakTimeInputFieldState, longBreakTimeInputFieldState = longBreakTimeInputFieldState,
sessionsSliderState = sessionsSliderState, 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 modifier = modifier
) )
} }
@@ -105,9 +141,63 @@ private fun SettingsScreen(
shortBreakTimeInputFieldState: TextFieldState, shortBreakTimeInputFieldState: TextFieldState,
longBreakTimeInputFieldState: TextFieldState, longBreakTimeInputFieldState: TextFieldState,
sessionsSliderState: SliderState, sessionsSliderState: SliderState,
alarmEnabled: Boolean,
vibrateEnabled: Boolean,
alarmSound: String,
onAlarmEnabledChange: (Boolean) -> Unit,
onVibrateEnabledChange: (Boolean) -> Unit,
onAlarmSoundChanged: (Uri?) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() 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)) { Column(modifier.nestedScroll(scrollBehavior.nestedScrollConnection)) {
TopAppBar( TopAppBar(
@@ -155,10 +245,10 @@ private fun SettingsScreen(
MinuteInputField( MinuteInputField(
state = focusTimeInputFieldState, state = focusTimeInputFieldState,
shape = RoundedCornerShape( shape = RoundedCornerShape(
topStart = 16.dp, topStart = topListItemShape.topStart,
bottomStart = 16.dp, bottomStart = topListItemShape.topStart,
topEnd = 4.dp, topEnd = topListItemShape.bottomStart,
bottomEnd = 4.dp bottomEnd = topListItemShape.bottomStart
), ),
imeAction = ImeAction.Next imeAction = ImeAction.Next
) )
@@ -174,7 +264,7 @@ private fun SettingsScreen(
) )
MinuteInputField( MinuteInputField(
state = shortBreakTimeInputFieldState, state = shortBreakTimeInputFieldState,
shape = RoundedCornerShape(4.dp), shape = RoundedCornerShape(middleListItemShape.topStart),
imeAction = ImeAction.Next imeAction = ImeAction.Next
) )
} }
@@ -190,10 +280,10 @@ private fun SettingsScreen(
MinuteInputField( MinuteInputField(
state = longBreakTimeInputFieldState, state = longBreakTimeInputFieldState,
shape = RoundedCornerShape( shape = RoundedCornerShape(
topStart = 4.dp, topStart = bottomListItemShape.topStart,
bottomStart = 4.dp, bottomStart = bottomListItemShape.topStart,
topEnd = 16.dp, topEnd = bottomListItemShape.bottomStart,
bottomEnd = 16.dp bottomEnd = bottomListItemShape.bottomStart
), ),
imeAction = ImeAction.Done imeAction = ImeAction.Done
) )
@@ -224,7 +314,68 @@ private fun SettingsScreen(
} }
}, },
colors = listItemColors, 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 { item {
@@ -275,7 +426,21 @@ fun SettingsScreenPreview() {
shortBreakTimeInputFieldState = rememberTextFieldState((5 * 60 * 1000).toString()), shortBreakTimeInputFieldState = rememberTextFieldState((5 * 60 * 1000).toString()),
longBreakTimeInputFieldState = rememberTextFieldState((15 * 60 * 1000).toString()), longBreakTimeInputFieldState = rememberTextFieldState((15 * 60 * 1000).toString()),
sessionsSliderState = rememberSliderState(value = 3f, steps = 3, valueRange = 1f..5f), sessionsSliderState = rememberSliderState(value = 3f, steps = 3, valueRange = 1f..5f),
alarmEnabled = true,
vibrateEnabled = true,
alarmSound = "null",
onAlarmEnabledChange = {},
onVibrateEnabledChange = {},
onAlarmSoundChanged = {},
modifier = Modifier.fillMaxSize() 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 package org.nsh07.pomodoro.ui.settingsScreen.viewModel
import android.net.Uri
import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SliderState import androidx.compose.material3.SliderState
@@ -20,6 +21,7 @@ import androidx.lifecycle.viewmodel.viewModelFactory
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.nsh07.pomodoro.TomatoApplication import org.nsh07.pomodoro.TomatoApplication
import org.nsh07.pomodoro.data.AppPreferenceRepository import org.nsh07.pomodoro.data.AppPreferenceRepository
@@ -44,6 +46,15 @@ class SettingsViewModel(
onValueChangeFinished = ::updateSessionLength 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 { init {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
snapshotFlow { focusTimeTextFieldState.text } 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 { companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory { val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer { 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, onClick = stopAlarm,
modifier = Modifier.align(Alignment.End), 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.draw.clip
import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -89,6 +91,7 @@ fun TimerScreen(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val motionScheme = motionScheme val motionScheme = motionScheme
val haptic = LocalHapticFeedback.current
val color by animateColorAsState( val color by animateColorAsState(
if (timerState.timerMode == TimerMode.FOCUS) colorScheme.primary if (timerState.timerMode == TimerMode.FOCUS) colorScheme.primary
@@ -111,9 +114,6 @@ fun TimerScreen(
onResult = {} onResult = {}
) )
if (timerState.alarmRinging)
AlarmDialog { onAction(TimerAction.StopAlarm) }
Column(modifier = modifier) { Column(modifier = modifier) {
TopAppBar( TopAppBar(
title = { title = {
@@ -301,6 +301,10 @@ fun TimerScreen(
FilledIconToggleButton( FilledIconToggleButton(
onCheckedChange = { checked -> onCheckedChange = { checked ->
onAction(TimerAction.ToggleTimer) onAction(TimerAction.ToggleTimer)
if (checked) haptic.performHapticFeedback(HapticFeedbackType.ToggleOn)
else haptic.performHapticFeedback(HapticFeedbackType.ToggleOff)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && checked) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && checked) {
permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
} }
@@ -358,7 +362,10 @@ fun TimerScreen(
customItem( customItem(
{ {
FilledTonalIconButton( FilledTonalIconButton(
onClick = { onAction(TimerAction.ResetTimer) }, onClick = {
onAction(TimerAction.ResetTimer)
haptic.performHapticFeedback(HapticFeedbackType.VirtualKey)
},
colors = IconButtonDefaults.filledTonalIconButtonColors( colors = IconButtonDefaults.filledTonalIconButtonColors(
containerColor = colorContainer containerColor = colorContainer
), ),
@@ -395,7 +402,10 @@ fun TimerScreen(
customItem( customItem(
{ {
FilledTonalIconButton( FilledTonalIconButton(
onClick = { onAction(TimerAction.SkipTimer(fromButton = true)) }, onClick = {
onAction(TimerAction.SkipTimer(fromButton = true))
haptic.performHapticFeedback(HapticFeedbackType.VirtualKey)
},
colors = IconButtonDefaults.filledTonalIconButtonColors( colors = IconButtonDefaults.filledTonalIconButtonColors(
containerColor = colorContainer containerColor = colorContainer
), ),

View File

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