Merge branch 'dev'
@@ -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"
|
||||
}
|
||||
|
||||
133
app/schemas/org.nsh07.pomodoro.data.AppDatabase/2.json
Normal 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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
43
app/src/main/java/org/nsh07/pomodoro/ui/theme/Shape.kt
Normal 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
|
||||
}
|
||||
@@ -69,7 +69,7 @@ fun AlarmDialog(
|
||||
onClick = stopAlarm,
|
||||
modifier = Modifier.align(Alignment.End),
|
||||
) {
|
||||
Text("Ok")
|
||||
Text("Dismiss")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
16
app/src/main/res/drawable/alarm_on.xml
Normal 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>
|
||||
16
app/src/main/res/drawable/check.xml
Normal 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>
|
||||
16
app/src/main/res/drawable/clear.xml
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
16
app/src/main/res/drawable/mobile_vibrate.xml
Normal 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>
|
||||
9
app/src/main/res/drawable/timer_filled.xml
Normal 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>
|
||||
9
app/src/main/res/drawable/timer_outlined.xml
Normal 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>
|
||||
9
fastlane/metadata/android/en-US/changelogs/7.txt
Normal 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
|
||||
|
Before Width: | Height: | Size: 322 KiB After Width: | Height: | Size: 324 KiB |
|
Before Width: | Height: | Size: 316 KiB After Width: | Height: | Size: 323 KiB |
|
Before Width: | Height: | Size: 329 KiB After Width: | Height: | Size: 330 KiB |
|
Before Width: | Height: | Size: 352 KiB After Width: | Height: | Size: 353 KiB |
|
Before Width: | Height: | Size: 372 KiB After Width: | Height: | Size: 385 KiB |
|
Before Width: | Height: | Size: 303 KiB After Width: | Height: | Size: 348 KiB |