diff --git a/app/schemas/com.bnyro.clock.data.database.AppDatabase/13.json b/app/schemas/com.bnyro.clock.data.database.AppDatabase/13.json new file mode 100644 index 000000000..3e20bc650 --- /dev/null +++ b/app/schemas/com.bnyro.clock.data.database.AppDatabase/13.json @@ -0,0 +1,207 @@ +{ + "formatVersion": 1, + "database": { + "version": 13, + "identityHash": "4d1e8d62329256d7dfde8d1afde39a07", + "entities": [ + { + "tableName": "timeZones", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `zoneId` TEXT NOT NULL, `zoneName` TEXT NOT NULL, `countryName` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "zoneId", + "columnName": "zoneId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "zoneName", + "columnName": "zoneName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "countryName", + "columnName": "countryName", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + } + }, + { + "tableName": "alarms", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `time` INTEGER NOT NULL, `label` TEXT, `enabled` INTEGER NOT NULL, `days` TEXT NOT NULL, `vibrate` INTEGER NOT NULL, `soundName` TEXT, `soundUri` TEXT, `snoozeEnabled` INTEGER NOT NULL DEFAULT 1, `snoozeMinutes` INTEGER NOT NULL DEFAULT 10, `soundEnabled` INTEGER NOT NULL DEFAULT 1, `vibrationPattern` TEXT NOT NULL DEFAULT '0,1000,1000,1000,1000', `vibrationPatternName` TEXT NOT NULL DEFAULT 'Default', `dismissedAt` INTEGER DEFAULT NULL, `startDate` INTEGER NOT NULL DEFAULT 0, `repeatInterval` INTEGER NOT NULL DEFAULT 1, `repeatUnit` TEXT NOT NULL DEFAULT 'WEEK', `repeatAnchor` TEXT NOT NULL DEFAULT 'DAY_OF_MONTH', `repeatDuration` INTEGER DEFAULT NULL, `repeatDurationUnit` TEXT NOT NULL DEFAULT 'DAY', `endDate` INTEGER DEFAULT NULL, `endOccurrences` INTEGER DEFAULT NULL, `advanced` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT" + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "days", + "columnName": "days", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vibrate", + "columnName": "vibrate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "soundName", + "columnName": "soundName", + "affinity": "TEXT" + }, + { + "fieldPath": "soundUri", + "columnName": "soundUri", + "affinity": "TEXT" + }, + { + "fieldPath": "snoozeEnabled", + "columnName": "snoozeEnabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "snoozeMinutes", + "columnName": "snoozeMinutes", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "10" + }, + { + "fieldPath": "soundEnabled", + "columnName": "soundEnabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "vibrationPattern", + "columnName": "vibrationPattern", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'0,1000,1000,1000,1000'" + }, + { + "fieldPath": "vibrationPatternName", + "columnName": "vibrationPatternName", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'Default'" + }, + { + "fieldPath": "dismissedAt", + "columnName": "dismissedAt", + "affinity": "INTEGER", + "defaultValue": "NULL" + }, + { + "fieldPath": "startDate", + "columnName": "startDate", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "repeatInterval", + "columnName": "repeatInterval", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "repeatUnit", + "columnName": "repeatUnit", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'WEEK'" + }, + { + "fieldPath": "repeatAnchor", + "columnName": "repeatAnchor", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'DAY_OF_MONTH'" + }, + { + "fieldPath": "repeatDuration", + "columnName": "repeatDuration", + "affinity": "INTEGER", + "defaultValue": "NULL" + }, + { + "fieldPath": "repeatDurationUnit", + "columnName": "repeatDurationUnit", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'DAY'" + }, + { + "fieldPath": "endDate", + "columnName": "endDate", + "affinity": "INTEGER", + "defaultValue": "NULL" + }, + { + "fieldPath": "endOccurrences", + "columnName": "endOccurrences", + "affinity": "INTEGER", + "defaultValue": "NULL" + }, + { + "fieldPath": "advanced", + "columnName": "advanced", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + } + ], + "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, '4d1e8d62329256d7dfde8d1afde39a07')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 733346297..55069199e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -134,6 +134,15 @@ + + = Build.VERSION_CODES.N) { @@ -116,7 +132,8 @@ abstract class AppDatabase : RoomDatabase() { MIGRATION_1_2, MIGRATION_3_4, MIGRATION_7_8, - MIGRATION_11_12 + MIGRATION_11_12, + MIGRATION_12_13 ) .build() INSTANCE = instance diff --git a/app/src/main/java/com/bnyro/clock/domain/model/Alarm.kt b/app/src/main/java/com/bnyro/clock/domain/model/Alarm.kt index 1a74ee274..a14300841 100644 --- a/app/src/main/java/com/bnyro/clock/domain/model/Alarm.kt +++ b/app/src/main/java/com/bnyro/clock/domain/model/Alarm.kt @@ -32,7 +32,7 @@ data class Alarm( @ColumnInfo(defaultValue = "1") var snoozeEnabled: Boolean = true, @ColumnInfo(defaultValue = "10") var snoozeMinutes: Int = 10, @ColumnInfo(defaultValue = "1") var soundEnabled: Boolean = true, - @ColumnInfo(defaultValue = "1000,1000,1000,1000,1000") var vibrationPattern: List = List(5) { 1000 }, + @ColumnInfo(defaultValue = "0,1000,1000,1000,1000") var vibrationPattern: List = listOf(0, 1000, 1000, 1000, 1000), @ColumnInfo(defaultValue = "Default") var vibrationPatternName: String = "Default", @ColumnInfo(defaultValue = "NULL") var dismissedAt: Long? = null, @ColumnInfo(defaultValue = "0") var startDate: Long = LocalDate.now().toEpochDay(), diff --git a/app/src/main/java/com/bnyro/clock/domain/model/PersistentTimer.kt b/app/src/main/java/com/bnyro/clock/domain/model/PersistentTimer.kt deleted file mode 100644 index b59731432..000000000 --- a/app/src/main/java/com/bnyro/clock/domain/model/PersistentTimer.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.bnyro.clock.domain.model - -import com.bnyro.clock.util.Preferences - -data class PersistentTimer( - val seconds: Int -) { - // Write a getter for formattedTime that returns a String in the format of "HH:MM:SS" - val formattedTime: String - get() { - val hours = seconds / 3600 - val minutes = (seconds % 3600) / 60 - val seconds = seconds % 60 - - return if (hours == 0) { - String.format("%02d:%02d", minutes, seconds) - } else { - String.format("%02d:%02d:%02d", hours, minutes, seconds) - } - } - - companion object { - private val exampleTimers = listOf( - 60 * 10, - 60 * 15, - 60 * 30, - 60 * 60 - ).map { PersistentTimer(it) } - - fun setTimers(timers: List) { - val delimitedString = timers.map { it.seconds }.joinToString(",") - Preferences.edit { - putString(Preferences.persistentTimerKey, delimitedString) - } - } - - fun getTimers(): List { - val delimitedString = - Preferences.instance.getString(Preferences.persistentTimerKey, null) - return delimitedString?.split(",")?.mapNotNull { - it.toIntOrNull() - }?.map { - PersistentTimer(it) - } ?: exampleTimers - } - } -} diff --git a/app/src/main/java/com/bnyro/clock/domain/model/TimerDescriptor.kt b/app/src/main/java/com/bnyro/clock/domain/model/TimerDescriptor.kt index 9316b8328..e05fca0cf 100644 --- a/app/src/main/java/com/bnyro/clock/domain/model/TimerDescriptor.kt +++ b/app/src/main/java/com/bnyro/clock/domain/model/TimerDescriptor.kt @@ -6,13 +6,21 @@ import kotlinx.parcelize.Parcelize @Parcelize data class TimerDescriptor( - var id: Int = 0, - var currentPosition: Int = 0, + var id: Int, + var settings: TimerSettings ) : Parcelable { fun asScheduledObject(): TimerObject { return TimerObject( id = id, - currentPosition = mutableStateOf(currentPosition), + label = mutableStateOf(settings.label), + currentPosition = mutableStateOf(settings.seconds * 1000), + soundName = settings.soundName, + soundUri = settings.soundUri, + soundEnabled = settings.soundEnabled, + vibrate = settings.vibrate, + vibrationPattern = settings.vibrationPattern, + vibrationPatternName = settings.vibrationPatternName, + incrementSeconds = settings.incrementSeconds ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bnyro/clock/domain/model/TimerObject.kt b/app/src/main/java/com/bnyro/clock/domain/model/TimerObject.kt index 332df474c..6025a7cf7 100644 --- a/app/src/main/java/com/bnyro/clock/domain/model/TimerObject.kt +++ b/app/src/main/java/com/bnyro/clock/domain/model/TimerObject.kt @@ -1,15 +1,43 @@ package com.bnyro.clock.domain.model -import android.net.Uri import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf +import com.bnyro.clock.util.Preferences +import kotlin.math.ceil data class TimerObject( var id: Int = 0, - var label: MutableState = mutableStateOf(null), + var label: MutableState = mutableStateOf(""), var currentPosition: MutableState = mutableStateOf(0), - val initialPosition: Int = currentPosition.value, + var initialPosition: MutableState = mutableStateOf(currentPosition.value), var state: MutableState = mutableStateOf(WatchState.IDLE), - var ringtone: Uri? = null, - var vibrate: Boolean = false -) + var soundName: String? = null, + var soundUri: String? = null, + var soundEnabled: Boolean = true, + var vibrate: Boolean = true, + var vibrationPattern: List = listOf(0, 1000, 1000, 1000, 1000), + var vibrationPatternName: String = "Default", + var incrementSeconds: Int? = null +) { + val secondsLeft: Int + get() = ceil(currentPosition.value / 1000.0).toInt() + + val effectiveIncrementSeconds: Int + get() = incrementSeconds ?: Preferences.instance.getInt( + Preferences.timerIncrementSecondsKey, + 60 + ) + + val settings: TimerSettings + get() = TimerSettings( + seconds = initialPosition.value / 1000, + label = label.value, + soundName = soundName, + soundUri = soundUri, + soundEnabled = soundEnabled, + vibrate = vibrate, + vibrationPattern = vibrationPattern, + vibrationPatternName = vibrationPatternName, + incrementSeconds = incrementSeconds + ) +} diff --git a/app/src/main/java/com/bnyro/clock/domain/model/TimerPickerBehaviour.kt b/app/src/main/java/com/bnyro/clock/domain/model/TimerPickerBehaviour.kt new file mode 100644 index 000000000..969df32c7 --- /dev/null +++ b/app/src/main/java/com/bnyro/clock/domain/model/TimerPickerBehaviour.kt @@ -0,0 +1,6 @@ +package com.bnyro.clock.domain.model + +enum class TimerPickerBehaviour { + HIDE, + KEEP_OPEN +} diff --git a/app/src/main/java/com/bnyro/clock/domain/model/TimerSettings.kt b/app/src/main/java/com/bnyro/clock/domain/model/TimerSettings.kt new file mode 100644 index 000000000..b1d249ef0 --- /dev/null +++ b/app/src/main/java/com/bnyro/clock/domain/model/TimerSettings.kt @@ -0,0 +1,54 @@ +package com.bnyro.clock.domain.model + +import android.os.Parcelable +import com.bnyro.clock.util.Preferences +import com.bnyro.clock.util.TimeHelper +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient +import kotlinx.serialization.json.Json + +/** + * Everything a timer is started with, and everything a saved timer keeps between runs. + * + * @property id Tells the saved timers apart while they are listed, and is handed out on + * reading them rather than kept, since it means nothing to a timer that is only being started. + * @property seconds The duration the timer counts down from. + * @property label The name of the timer, which falls back to the duration it was set to. + */ +@Parcelize +@Serializable +data class TimerSettings( + @Transient val id: Int = 0, + val seconds: Int, + val label: String = TimeHelper.durationToName(seconds), + val soundName: String? = null, + val soundUri: String? = null, + val soundEnabled: Boolean = true, + val vibrate: Boolean = true, + val vibrationPattern: List = listOf(0, 1000, 1000, 1000, 1000), + val vibrationPatternName: String = "Default", + val incrementSeconds: Int? = null +) : Parcelable { + companion object { + private val exampleTimers = listOf( + 60 * 5, + 60 * 10, + 60 * 15, + 60 * 30 + ).mapIndexed { index, seconds -> TimerSettings(id = index + 1, seconds = seconds) } + + fun setSavedTimers(timers: List) { + Preferences.edit { + putString(Preferences.savedTimersKey, Json.encodeToString(timers)) + } + } + + fun getSavedTimers(): List { + val savedTimers = Preferences.instance.getString(Preferences.savedTimersKey, null) + ?: return exampleTimers + return Json.decodeFromString>(savedTimers) + .mapIndexed { index, timer -> timer.copy(id = index + 1) } + } + } +} diff --git a/app/src/main/java/com/bnyro/clock/navigation/HomeRoutes.kt b/app/src/main/java/com/bnyro/clock/navigation/HomeRoutes.kt index 0e0656026..09ef0e5ef 100644 --- a/app/src/main/java/com/bnyro/clock/navigation/HomeRoutes.kt +++ b/app/src/main/java/com/bnyro/clock/navigation/HomeRoutes.kt @@ -3,7 +3,7 @@ package com.bnyro.clock.navigation import androidx.annotation.StringRes import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Alarm -import androidx.compose.material.icons.filled.AvTimer +import androidx.compose.material.icons.filled.HourglassBottom import androidx.compose.material.icons.filled.Schedule import androidx.compose.material.icons.outlined.Timer import androidx.compose.ui.graphics.vector.ImageVector @@ -16,7 +16,7 @@ sealed class HomeRoutes( ) { object Alarm : HomeRoutes("alarm", R.string.alarm, Icons.Default.Alarm) object Clock : HomeRoutes("clock", R.string.clock, Icons.Default.Schedule) - object Timer : HomeRoutes("timer", R.string.timer, Icons.Default.AvTimer) + object Timer : HomeRoutes("timer", R.string.timer, Icons.Default.HourglassBottom) object Stopwatch : HomeRoutes("stopwatch", R.string.stopwatch, Icons.Outlined.Timer) } diff --git a/app/src/main/java/com/bnyro/clock/navigation/NavHost.kt b/app/src/main/java/com/bnyro/clock/navigation/NavHost.kt index c73b7ca07..e3cc05208 100644 --- a/app/src/main/java/com/bnyro/clock/navigation/NavHost.kt +++ b/app/src/main/java/com/bnyro/clock/navigation/NavHost.kt @@ -71,7 +71,7 @@ fun AppNavHost( navController.popBackStack() }, onNavigate = { navController.navigate(it) - }, settingsModel, timerModel) + }, settingsModel) } composable(NavRoutes.Widgets.route, diff --git a/app/src/main/java/com/bnyro/clock/presentation/components/ScrollTimerPicker.kt b/app/src/main/java/com/bnyro/clock/presentation/components/ScrollTimerPicker.kt index 86a56b87e..65b53f410 100644 --- a/app/src/main/java/com/bnyro/clock/presentation/components/ScrollTimerPicker.kt +++ b/app/src/main/java/com/bnyro/clock/presentation/components/ScrollTimerPicker.kt @@ -1,15 +1,20 @@ package com.bnyro.clock.presentation.components import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalLayoutDirection @@ -17,80 +22,101 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import com.bnyro.clock.R -import com.bnyro.clock.presentation.screens.timer.model.TimerModel @Composable -fun ScrollTimerPicker(timerModel: TimerModel) { - CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp) - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text( - text = stringResource(R.string.hours), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary - ) - Spacer(modifier = Modifier.height(32.dp)) - ScrollWheel( - value = remember { timerModel.hours }, - onValueChanged = { timerModel.hours = it }, - maxValue = 24 - ) - } - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text( - text = "", - style = MaterialTheme.typography.bodyMedium - ) - Spacer(modifier = Modifier.height(32.dp)) - Text( - text = ":", - style = MaterialTheme.typography.displayMedium, - color = MaterialTheme.colorScheme.primary - ) - } +fun ScrollTimerPicker(seconds: Int, onSecondsChanged: (Int) -> Unit) { + var chosenHours by remember { mutableIntStateOf(seconds / 3600) } + var chosenMinutes by remember { mutableIntStateOf(seconds % 3600 / 60) } + var chosenSeconds by remember { mutableIntStateOf(seconds % 60) } - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text( - text = stringResource(R.string.minutes), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary - ) - Spacer(modifier = Modifier.height(32.dp)) - ScrollWheel( - value = remember { timerModel.minutes }, - onValueChanged = { timerModel.minutes = it }, - maxValue = 60 - ) - } + val pushDuration = { + onSecondsChanged(chosenHours * 3600 + chosenMinutes * 60 + chosenSeconds) + } - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text( - text = "", - style = MaterialTheme.typography.bodyMedium - ) - Spacer(modifier = Modifier.height(32.dp)) - Text( - text = ":", - style = MaterialTheme.typography.displayMedium, - color = MaterialTheme.colorScheme.primary - ) - } - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text( - text = stringResource(R.string.seconds), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary - ) - Spacer(modifier = Modifier.height(32.dp)) - ScrollWheel( - value = remember { timerModel.seconds }, - onValueChanged = { timerModel.seconds = it }, - maxValue = 60 - ) + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = stringResource(R.string.hours), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(32.dp)) + ScrollWheel( + value = chosenHours, + onValueChanged = { + chosenHours = it + pushDuration() + }, + maxValue = 24 + ) + } + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "", + style = MaterialTheme.typography.bodyMedium + ) + Spacer(modifier = Modifier.height(32.dp)) + Text( + text = ":", + style = MaterialTheme.typography.displayMedium, + color = MaterialTheme.colorScheme.primary + ) + } + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = stringResource(R.string.minutes), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(32.dp)) + ScrollWheel( + value = chosenMinutes, + onValueChanged = { + chosenMinutes = it + pushDuration() + }, + maxValue = 60 + ) + } + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "", + style = MaterialTheme.typography.bodyMedium + ) + Spacer(modifier = Modifier.height(32.dp)) + Text( + text = ":", + style = MaterialTheme.typography.displayMedium, + color = MaterialTheme.colorScheme.primary + ) + } + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = stringResource(R.string.seconds), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(32.dp)) + ScrollWheel( + value = chosenSeconds, + onValueChanged = { + chosenSeconds = it + pushDuration() + }, + maxValue = 60 + ) + } } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bnyro/clock/presentation/components/ScrollWheel.kt b/app/src/main/java/com/bnyro/clock/presentation/components/ScrollWheel.kt index fd5052d7e..70895d4d2 100644 --- a/app/src/main/java/com/bnyro/clock/presentation/components/ScrollWheel.kt +++ b/app/src/main/java/com/bnyro/clock/presentation/components/ScrollWheel.kt @@ -20,13 +20,23 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp import kotlin.math.abs +/** + * The room one character of a wheel's label takes at the size the wheel draws it. + */ +private val LABEL_WIDTH = 32.dp + @OptIn(ExperimentalFoundationApi::class) @Composable fun ScrollWheel( @@ -46,6 +56,20 @@ fun ScrollWheel( val widestLabel = remember(maxValue, offset) { (0 until maxValue).maxOf { label(it + offset).length } } + // the wheel usually sits on a page that scrolls the same way it does, so a drag that + // lands on it is its alone and never reaches the page behind + val keepDragsOnTheWheel = remember { + object : NestedScrollConnection { + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource + ) = available.copy(x = 0f) + + override suspend fun onPostFling(consumed: Velocity, available: Velocity) = + available.copy(x = 0f) + } + } // a slow drag is answered by the row under the highlight, whatever the wheel was // doing on the way there; only a real flick is allowed to carry on and coast val minFlickVelocity = with(LocalDensity.current) { 400.dp.toPx() } @@ -82,8 +106,9 @@ fun ScrollWheel( } VerticalPager( modifier = Modifier + .nestedScroll(keepDragsOnTheWheel) .height(224.dp) - .widthIn(min = if (widestLabel >= 3) 96.dp else 0.dp), + .widthIn(min = (LABEL_WIDTH * widestLabel)), state = state, pageSpacing = 16.dp, pageSize = PageSize.Fixed(64.dp), diff --git a/app/src/main/java/com/bnyro/clock/presentation/components/SwitchWithDivider.kt b/app/src/main/java/com/bnyro/clock/presentation/components/SwitchWithDivider.kt index a414794b8..6212be496 100644 --- a/app/src/main/java/com/bnyro/clock/presentation/components/SwitchWithDivider.kt +++ b/app/src/main/java/com/bnyro/clock/presentation/components/SwitchWithDivider.kt @@ -19,6 +19,7 @@ import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics @@ -37,7 +38,8 @@ fun SwitchWithDivider( Surface( modifier = Modifier.clickable( onClick = onClick - ) + ), + color = Color.Transparent ) { Row( modifier = Modifier @@ -100,7 +102,8 @@ fun SwitchItem( modifier = Modifier.toggleable( value = isChecked, onValueChange = onClick - ) + ), + color = Color.Transparent ) { Row( modifier = Modifier diff --git a/app/src/main/java/com/bnyro/clock/presentation/features/TimerReceiverDialog.kt b/app/src/main/java/com/bnyro/clock/presentation/features/TimerReceiverDialog.kt index 9c75def81..b35d221a9 100644 --- a/app/src/main/java/com/bnyro/clock/presentation/features/TimerReceiverDialog.kt +++ b/app/src/main/java/com/bnyro/clock/presentation/features/TimerReceiverDialog.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.lifecycle.viewmodel.compose.viewModel import com.bnyro.clock.R +import com.bnyro.clock.domain.model.TimerSettings import com.bnyro.clock.presentation.components.DialogButton import com.bnyro.clock.presentation.components.DialogButtonStyle import com.bnyro.clock.presentation.screens.timer.model.TimerModel @@ -38,7 +39,7 @@ fun TimerReceiverDialog(duration: Int) { }, confirmButton = { DialogButton(R.string.start, DialogButtonStyle.PRIMARY) { - timerModel.startTimer(context, duration) + timerModel.startTimer(context, TimerSettings(seconds = duration)) showDialog = false } }, diff --git a/app/src/main/java/com/bnyro/clock/presentation/screens/alarm/AlarmActivity.kt b/app/src/main/java/com/bnyro/clock/presentation/screens/alarm/AlarmActivity.kt index 6167e6f2a..24185ebb9 100644 --- a/app/src/main/java/com/bnyro/clock/presentation/screens/alarm/AlarmActivity.kt +++ b/app/src/main/java/com/bnyro/clock/presentation/screens/alarm/AlarmActivity.kt @@ -1,121 +1,44 @@ package com.bnyro.clock.presentation.screens.alarm -import android.app.KeyguardManager -import android.content.BroadcastReceiver -import android.content.Context import android.content.Intent -import android.content.IntentFilter -import android.hardware.Sensor -import android.hardware.SensorEvent -import android.hardware.SensorEventListener -import android.hardware.SensorManager -import android.media.AudioManager -import android.os.Build import android.os.Bundle -import android.view.KeyEvent -import android.view.Window -import android.view.WindowManager -import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.core.content.ContextCompat import com.bnyro.clock.App import com.bnyro.clock.domain.model.Alarm -import com.bnyro.clock.domain.model.VolumeButtonAction +import com.bnyro.clock.presentation.screens.ringing.RingingActivity import com.bnyro.clock.util.AlarmHelper import com.bnyro.clock.util.Preferences import com.bnyro.clock.util.services.AlarmService import kotlinx.coroutines.runBlocking -class AlarmActivity : ComponentActivity() { +class AlarmActivity : RingingActivity() { private var alarm by mutableStateOf(Alarm(0, 0)) - private val sensorManager: SensorManager by lazy { - getSystemService(Context.SENSOR_SERVICE) as SensorManager - } - private val gravitySensor: Sensor by lazy { - sensorManager.getDefaultSensor(Sensor.TYPE_GRAVITY) as Sensor - } - private var facingDownInitially: Boolean? = null + override val closeAction = ALARM_ALERT_CLOSE_ACTION - private val closeAlertReciever = object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - if (intent?.getStringExtra(ACTION_EXTRA_KEY) == CLOSE_ACTION) { - android.util.Log.d("AlarmActivity", "alrmclsD:") - finish() - } - } - } - - private val sensorEventListener = object : SensorEventListener { - override fun onSensorChanged(event: SensorEvent) { - val gravityThreshold = SensorManager.GRAVITY_EARTH * 0.95f - - if (event.sensor.type == Sensor.TYPE_GRAVITY) { - val isDown = event.values[2] < -gravityThreshold - if (facingDownInitially == null) { - facingDownInitially = isDown - return - } - if (isDown && facingDownInitially != true) { - dismiss() - } - } - } + override val volumeButtonActionKey = Preferences.volumeButtonActionKey - override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit - } + override val snoozeAvailable get() = alarm.snoozeEnabled override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - volumeControlStream = AudioManager.STREAM_ALARM - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { - setShowWhenLocked(true) - setTurnScreenOn(true) - - val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager - keyguardManager.requestDismissKeyguard(this, null) - } else { - @Suppress("DEPRECATION") - window.addFlags( - WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or - WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD or - WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON - ) - } - - window.addFlags( - WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or - WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON - ) - - requestWindowFeature(Window.FEATURE_NO_TITLE) - - ContextCompat.registerReceiver( - this, - closeAlertReciever, - IntentFilter(ALARM_ALERT_CLOSE_ACTION), - ContextCompat.RECEIVER_NOT_EXPORTED - ) - - enableEdgeToEdge() setContent { AlarmAlertScreen( onDismiss = this@AlarmActivity::dismiss, onSnooze = this@AlarmActivity::snooze, label = alarm.label, snoozeEnabled = alarm.snoozeEnabled, - snoozeTime = alarm.snoozeMinutes + snoozeTime = alarm.snoozeMinutes, + alarmTimeMillis = alarm.time ) } handleIntent(intent) } - private fun dismiss() { + override fun dismiss() { stopService( Intent( this@AlarmActivity.applicationContext, @@ -125,7 +48,9 @@ class AlarmActivity : ComponentActivity() { this@AlarmActivity.finish() } - private fun snooze(minutes: Int = alarm.snoozeMinutes) { + override fun snooze() = snooze(alarm.snoozeMinutes) + + private fun snooze(minutes: Int) { stopService( Intent( this@AlarmActivity.applicationContext, @@ -136,32 +61,6 @@ class AlarmActivity : ComponentActivity() { this@AlarmActivity.finish() } - override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { - if (keyCode != KeyEvent.KEYCODE_VOLUME_DOWN && keyCode != KeyEvent.KEYCODE_VOLUME_UP) { - return super.onKeyDown(keyCode, event) - } - - return when ( - VolumeButtonAction.valueOf( - Preferences.instance.getString( - Preferences.volumeButtonActionKey, - VolumeButtonAction.SNOOZE.name - ) ?: VolumeButtonAction.SNOOZE.name - ) - ) { - VolumeButtonAction.SNOOZE -> { - if (alarm.snoozeEnabled) snooze() else dismiss() - true - } - VolumeButtonAction.DISMISS -> { - dismiss() - true - } - VolumeButtonAction.CONTROL_VOLUME -> super.onKeyDown(keyCode, event) - VolumeButtonAction.DO_NOTHING -> true - } - } - override fun onNewIntent(intent: Intent) { handleIntent(intent) super.onNewIntent(intent) @@ -175,28 +74,25 @@ class AlarmActivity : ComponentActivity() { } ?: return } - override fun onDestroy() { - unregisterReceiver(closeAlertReciever) - super.onDestroy() - } - - override fun onResume() { - super.onResume() - sensorManager.registerListener( - sensorEventListener, - gravitySensor, - SensorManager.SENSOR_DELAY_NORMAL + override fun onStart() { + super.onStart() + sendBroadcast( + Intent(AlarmService.ALARM_INTENT_ACTION) + .putExtra(AlarmService.ACTION_EXTRA_KEY, AlarmService.ALERT_SHOWN_ACTION) + .setPackage(packageName) ) } - override fun onPause() { - super.onPause() - sensorManager.unregisterListener(sensorEventListener) + override fun onStop() { + sendBroadcast( + Intent(AlarmService.ALARM_INTENT_ACTION) + .putExtra(AlarmService.ACTION_EXTRA_KEY, AlarmService.ALERT_HIDDEN_ACTION) + .setPackage(packageName) + ) + super.onStop() } companion object { const val ALARM_ALERT_CLOSE_ACTION = "com.bnyro.clock.ALARM_ALERT_CLOSE_ACTION" - const val ACTION_EXTRA_KEY = "action" - const val CLOSE_ACTION = "CLOSE" } } diff --git a/app/src/main/java/com/bnyro/clock/presentation/screens/alarm/AlarmAlertScreen.kt b/app/src/main/java/com/bnyro/clock/presentation/screens/alarm/AlarmAlertScreen.kt index 7f6c76145..e7dceeef3 100644 --- a/app/src/main/java/com/bnyro/clock/presentation/screens/alarm/AlarmAlertScreen.kt +++ b/app/src/main/java/com/bnyro/clock/presentation/screens/alarm/AlarmAlertScreen.kt @@ -1,27 +1,13 @@ package com.bnyro.clock.presentation.screens.alarm import android.content.res.Configuration -import android.content.res.Configuration.ORIENTATION_PORTRAIT -import androidx.compose.animation.core.Ease -import androidx.compose.animation.core.EaseInOutBack -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.tween -import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Add @@ -32,32 +18,24 @@ import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.rotate -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel import com.bnyro.clock.R -import com.bnyro.clock.presentation.screens.settings.model.SettingsModel -import com.bnyro.clock.ui.theme.ClockYouTheme -import com.bnyro.clock.util.ThemeUtil -import com.bnyro.clock.util.TimeHelper -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive +import com.bnyro.clock.presentation.screens.ringing.RingingAlert +import com.bnyro.clock.presentation.screens.ringing.RingingTitle +import java.time.LocalDate +import java.time.LocalTime +import java.time.ZoneId @Composable fun AlarmAlertScreen( @@ -65,82 +43,29 @@ fun AlarmAlertScreen( onSnooze: (minutes: Int) -> Unit, label: String? = null, snoozeEnabled: Boolean, - snoozeTime: Int + snoozeTime: Int, + alarmTimeMillis: Long ) { - val settingsModel: SettingsModel = viewModel() - ClockYouTheme( - darkTheme = true, - customColorScheme = ThemeUtil.getSchemeFromSeed( - settingsModel.customColor, - true - ) - ) { - val orientation = LocalConfiguration.current.orientation - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background - ) { - if (orientation == ORIENTATION_PORTRAIT) { - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.SpaceEvenly, - horizontalAlignment = Alignment.CenterHorizontally - ) { - AlarmAnimation() - AlarmControls(label, snoozeTime, snoozeEnabled, onSnooze, onDismiss) - } - } else { - Row { - Column( - modifier = Modifier - .fillMaxHeight() - .weight(2f), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - AlarmAnimation() - } - - Column( - modifier = Modifier - .fillMaxHeight() - .weight(3f), - verticalArrangement = Arrangement.SpaceEvenly, - horizontalAlignment = Alignment.CenterHorizontally - ) { - AlarmControls(label, snoozeTime, snoozeEnabled, onSnooze, onDismiss) - } - } - } - } + RingingAlert(painterResource(id = R.drawable.ic_alarm)) { + AlarmControls(label, alarmTimeMillis, snoozeTime, snoozeEnabled, onSnooze, onDismiss) } } @Composable private fun AlarmControls( label: String?, + alarmTimeMillis: Long, snoozeTime: Int, snoozeEnabled: Boolean, onSnooze: (minutes: Int) -> Unit, onDismiss: () -> Unit ) { - val context = LocalContext.current - val time by produceState( - initialValue = TimeHelper.formatTime(context, TimeHelper.getTimeByZone()), - producer = { - while (isActive) { - value = TimeHelper.formatTime(context, TimeHelper.getTimeByZone()) - delay(1000) - } - } - ) - Text( - text = time, - style = MaterialTheme.typography.displayMedium + RingingTitle( + label, + time = LocalDate.now() + .atTime(LocalTime.ofSecondOfDay(alarmTimeMillis / 1000)) + .atZone(ZoneId.systemDefault()) ) - label?.let { - Text(text = it, style = MaterialTheme.typography.headlineMedium) - } Column( Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally @@ -202,42 +127,6 @@ private fun AlarmControls( } } -@Composable -private fun AlarmAnimation() { - val infiniteTransition = rememberInfiniteTransition(label = "") - val rotation by infiniteTransition.animateFloat( - initialValue = -10F, - targetValue = 10F, - animationSpec = infiniteRepeatable( - animation = tween(400, easing = EaseInOutBack), - repeatMode = RepeatMode.Reverse - ), - label = "" - ) - val offset by infiniteTransition.animateFloat( - initialValue = 10F, - targetValue = -10F, - animationSpec = infiniteRepeatable( - animation = tween(200, easing = Ease), - repeatMode = RepeatMode.Reverse - ), - label = "" - ) - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .offset(y = offset.dp) - .rotate(rotation) - ) { - Image( - modifier = Modifier.size(250.dp), - painter = painterResource(id = R.drawable.ic_alarm), - contentDescription = null, - colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary) - ) - } -} - @Preview( showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL, @@ -247,7 +136,7 @@ private fun AlarmAnimation() { @Composable private fun DefaultPreview() { AlarmAlertScreen(onDismiss = {}, onSnooze = {}, snoozeTime = 10, label = "Test Alarm", - snoozeEnabled = true + snoozeEnabled = true, alarmTimeMillis = 7 * 60 * 60 * 1000L + 30 * 60 * 1000L ) } @@ -256,7 +145,7 @@ private fun DefaultPreview() { ) @Composable private fun ControllerPreview() { - AlarmControls(label = "Alarm", snoozeTime = 10, snoozeEnabled = true, onSnooze = {}) { + AlarmControls(label = "Alarm", alarmTimeMillis = 7 * 60 * 60 * 1000L + 30 * 60 * 1000L, snoozeTime = 10, snoozeEnabled = true, onSnooze = {}) { } } diff --git a/app/src/main/java/com/bnyro/clock/presentation/screens/alarm/AlarmScreen.kt b/app/src/main/java/com/bnyro/clock/presentation/screens/alarm/AlarmScreen.kt index 2d0aa663b..5a240cf4e 100644 --- a/app/src/main/java/com/bnyro/clock/presentation/screens/alarm/AlarmScreen.kt +++ b/app/src/main/java/com/bnyro/clock/presentation/screens/alarm/AlarmScreen.kt @@ -54,6 +54,8 @@ import com.bnyro.clock.presentation.screens.alarm.components.AlarmFilterSection import com.bnyro.clock.presentation.screens.alarm.components.AlarmItem import com.bnyro.clock.presentation.screens.alarm.model.AlarmModel import com.bnyro.clock.presentation.screens.settings.model.SettingsModel +import com.bnyro.clock.ui.theme.ItemFade +import com.bnyro.clock.ui.theme.ItemSlide import com.bnyro.clock.util.AlarmHelper private val FAB_SIZE = 56.dp @@ -77,7 +79,7 @@ fun AlarmScreen( TopBarScaffold( title = if (isSelectionMode) { - "${selectedAlarmIds.size} Selected" + stringResource(R.string.selected_count, selectedAlarmIds.size) } else { stringResource(R.string.alarm) }, @@ -192,7 +194,7 @@ fun AlarmScreen( LazyColumn(Modifier.fillMaxSize()) { items( items = alarms, - key = { it.id.toString() + "-" + it.enabled } + key = { it.id } ) { alarm -> val isSelected = selectedAlarmIds.contains(alarm.id) @@ -230,11 +232,12 @@ fun AlarmScreen( AlarmHelper.showAlarmScheduledToast(context, updatedAlarm) } } - } + }, + modifier = Modifier.animateItem(ItemFade, ItemSlide, ItemFade) ) } - item { + item(key = "bottomSpacer") { Spacer(modifier = Modifier.height(80.dp)) } } diff --git a/app/src/main/java/com/bnyro/clock/presentation/screens/alarm/components/AlarmCard.kt b/app/src/main/java/com/bnyro/clock/presentation/screens/alarm/components/AlarmCard.kt index 0c53abcc4..7d1cf3b13 100644 --- a/app/src/main/java/com/bnyro/clock/presentation/screens/alarm/components/AlarmCard.kt +++ b/app/src/main/java/com/bnyro/clock/presentation/screens/alarm/components/AlarmCard.kt @@ -63,21 +63,91 @@ fun AlarmCard( ) { val millisRemaining = AlarmHelper.getAlarmTime(alarm) ?.minus(System.currentTimeMillis()) - alarm.label?.let { - Row( - modifier = Modifier - .padding(start = 5.dp, end = 10.dp) - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { + Row( + modifier = Modifier + .padding(end = 10.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + alarm.label?.let { Icon(Icons.AutoMirrored.Filled.Label, null) Spacer(modifier = Modifier.width(5.dp)) Text( text = it, + modifier = Modifier.weight(1f, fill = false), overflow = TextOverflow.Ellipsis, maxLines = 1 ) + Spacer(modifier = Modifier.width(8.dp)) + } + val repeatDuration = alarm.repeatDuration + when { + alarm.isOneTime -> { + Text(text = stringResource(R.string.one_time)) + } + + repeatDuration != null -> { + Text( + text = stringResource( + R.string.repeats_for_every, + repeatDuration, + pluralStringResource( + id = alarm.repeatDurationUnit.value, + count = repeatDuration + ), + alarm.repeatInterval, + pluralStringResource( + id = alarm.repeatUnit.value, + count = alarm.repeatInterval + ) + ) + ) + } + + alarm.repeatUnit != RepeatUnit.WEEK || alarm.repeatInterval > 1 -> { + Text( + text = pluralStringResource( + id = alarm.repeatUnit.summary, + count = alarm.repeatInterval, + alarm.repeatInterval + ) + ) + } + + alarm.isRepeatEveryday -> { + Text(text = stringResource(R.string.every_day)) + } + + alarm.isWeekends -> { + Text(text = stringResource(R.string.weekends)) + } + + alarm.isWeekdays -> { + Text(text = stringResource(R.string.weekdays)) + } + + else -> { + val daysOfWeek = remember { + AlarmHelper.getDaysOfWeekForDisplay(context) + } + daysOfWeek.forEach { (day, index) -> + val enabled = alarm.days.contains(index) + Text( + modifier = Modifier.padding(horizontal = 2.dp), + text = day, + color = if (enabled) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface.copy( + alpha = 0.5f + ) + }, + fontWeight = FontWeight.Normal, + letterSpacing = 1.sp + ) + } + } } } Spacer(modifier = Modifier.height(5.dp)) @@ -103,64 +173,10 @@ fun AlarmCard( DialogButton(R.string.dismiss, DialogButtonStyle.SECONDARY, onDismiss) } - Row(verticalAlignment = Alignment.CenterVertically) { - Row(Modifier.padding(horizontal = 8.dp)) { - when { - alarm.isOneTime -> { - Text(text = stringResource(R.string.one_time)) - } - - alarm.repeatUnit != RepeatUnit.WEEK || alarm.repeatInterval > 1 -> { - Text( - text = pluralStringResource( - id = alarm.repeatUnit.summary, - count = alarm.repeatInterval, - alarm.repeatInterval - ) - ) - } - - alarm.isRepeatEveryday -> { - Text(text = stringResource(R.string.repeating)) - } - - alarm.isWeekends -> { - Text(text = stringResource(R.string.weekends)) - } - - alarm.isWeekdays -> { - Text(text = stringResource(R.string.weekdays)) - } - - else -> { - val daysOfWeek = remember { - AlarmHelper.getDaysOfWeekForDisplay(context) - } - daysOfWeek.forEach { (day, index) -> - val enabled = alarm.days.contains(index) - Text( - modifier = Modifier.padding(horizontal = 2.dp), - text = day, - color = if (enabled) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurface.copy( - alpha = 0.5f - ) - }, - fontWeight = FontWeight.Normal, - letterSpacing = 1.sp - ) - } - } - } - } - - Switch( - checked = isAlarmEnabled, - onCheckedChange = onEnable - ) - } + Switch( + checked = isAlarmEnabled, + onCheckedChange = onEnable + ) } } } diff --git a/app/src/main/java/com/bnyro/clock/presentation/screens/alarm/components/AlarmItem.kt b/app/src/main/java/com/bnyro/clock/presentation/screens/alarm/components/AlarmItem.kt index 910b4fbc0..032255a4b 100644 --- a/app/src/main/java/com/bnyro/clock/presentation/screens/alarm/components/AlarmItem.kt +++ b/app/src/main/java/com/bnyro/clock/presentation/screens/alarm/components/AlarmItem.kt @@ -49,10 +49,11 @@ fun AlarmItem( onLongClick: (Alarm) -> Unit, onUpdateAlarm: (Alarm) -> Unit, onDeleteAlarm: (Alarm) -> Unit, - onDismissAlarm: (Alarm) -> Unit + onDismissAlarm: (Alarm) -> Unit, + modifier: Modifier = Modifier ) { var showDeletionDialog by remember { mutableStateOf(false) } - var isAlarmEnabled by remember { mutableStateOf(alarm.enabled) } + var isAlarmEnabled by remember(alarm.id, alarm.enabled) { mutableStateOf(alarm.enabled) } val alarmTime = AlarmHelper.getAlarmTime(alarm) var canDismiss by remember(alarm.id, isAlarmEnabled, alarm.dismissedAt, alarmTime) { val timeUntilAlarm = alarmTime?.minus(System.currentTimeMillis()) @@ -83,6 +84,7 @@ fun AlarmItem( ) SwipeToDismissBox( + modifier = modifier, state = dismissState, enableDismissFromStartToEnd = !isSelectionMode, enableDismissFromEndToStart = false, diff --git a/app/src/main/java/com/bnyro/clock/presentation/screens/alarm/components/AlarmNumberKeypad.kt b/app/src/main/java/com/bnyro/clock/presentation/screens/alarm/components/AlarmNumberKeypad.kt deleted file mode 100644 index 139b04ff0..000000000 --- a/app/src/main/java/com/bnyro/clock/presentation/screens/alarm/components/AlarmNumberKeypad.kt +++ /dev/null @@ -1,143 +0,0 @@ -package com.bnyro.clock.presentation.screens.timer.components - -import android.view.HapticFeedbackConstants -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Backspace -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.surfaceColorAtElevation -import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import com.bnyro.clock.R -import com.bnyro.clock.domain.model.NumberKeypadOperation -import kotlinx.coroutines.launch - -@Composable -fun AlarmNumberKeypad( - onOperation: (NumberKeypadOperation) -> Unit, - modifier: Modifier = Modifier -) { - val view = LocalView.current - val coroutineScope = rememberCoroutineScope() - val buttonSpacing = 12.dp - - Column( - modifier = modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(buttonSpacing) - ) { - val rows = listOf( - listOf("1", "2", "3"), - listOf("4", "5", "6"), - listOf("7", "8", "9") - ) - - rows.forEach { rowNumbers -> - Row( - horizontalArrangement = Arrangement.spacedBy(buttonSpacing), - modifier = Modifier.fillMaxWidth() - ) { - rowNumbers.forEach { number -> - AlarmNumPadButton( - number = number, - modifier = Modifier.weight(1f), - onOperation = onOperation - ) - } - } - } - - // Bottom row - Row( - horizontalArrangement = Arrangement.spacedBy(buttonSpacing), - modifier = Modifier.fillMaxWidth() - ) { - AlarmNumPadButton( - number = "00", - modifier = Modifier.weight(1f), - onOperation = onOperation - ) - AlarmNumPadButton( - number = "0", - modifier = Modifier.weight(1f), - onOperation = onOperation - ) - - SingleElementButton( - onClick = { - coroutineScope.launch { - view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) - } - onOperation(NumberKeypadOperation.Delete) - }, - onLongClick = { - coroutineScope.launch { - view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) - } - onOperation(NumberKeypadOperation.Clear) - }, - color = MaterialTheme.colorScheme.secondaryContainer, - modifier = Modifier - .weight(1f) - .aspectRatio(1f) - ) { - Box( - modifier = Modifier.fillMaxWidth(), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.Backspace, - contentDescription = stringResource(R.string.delete), - tint = MaterialTheme.colorScheme.onSecondaryContainer - ) - } - } - } - } -} - -@Composable -fun AlarmNumPadButton( - number: String, - modifier: Modifier = Modifier, - onOperation: (NumberKeypadOperation) -> Unit -) { - val view = LocalView.current - val coroutineScope = rememberCoroutineScope() - - SingleElementButton( - onClick = { - coroutineScope.launch { - view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) - } - onOperation(NumberKeypadOperation.AddNumber(number)) - }, - modifier = modifier.aspectRatio(1f), - color = MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp) - ) { - Box( - modifier = Modifier.fillMaxWidth(), - contentAlignment = Alignment.Center - ) { - Text( - text = number, - color = MaterialTheme.colorScheme.onSurface, - fontSize = MaterialTheme.typography.displaySmall.fontSize - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/bnyro/clock/presentation/screens/alarm/components/AlarmTimePicker.kt b/app/src/main/java/com/bnyro/clock/presentation/screens/alarm/components/AlarmTimePicker.kt index e8eead0d6..09e4a6683 100644 --- a/app/src/main/java/com/bnyro/clock/presentation/screens/alarm/components/AlarmTimePicker.kt +++ b/app/src/main/java/com/bnyro/clock/presentation/screens/alarm/components/AlarmTimePicker.kt @@ -29,7 +29,7 @@ import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.bnyro.clock.domain.model.NumberKeypadOperation -import com.bnyro.clock.presentation.screens.timer.components.AlarmNumberKeypad +import com.bnyro.clock.presentation.screens.timer.components.NumberKeypad @Composable fun AlarmTimePicker( @@ -142,7 +142,7 @@ fun AlarmTimePicker( } } - AlarmNumberKeypad( + NumberKeypad( onOperation = { operation -> when (operation) { is NumberKeypadOperation.AddNumber -> { diff --git a/app/src/main/java/com/bnyro/clock/presentation/screens/alarm/components/ScrollAlarmTimePicker.kt b/app/src/main/java/com/bnyro/clock/presentation/screens/alarm/components/ScrollAlarmTimePicker.kt index 2aea21472..62ad9c853 100644 --- a/app/src/main/java/com/bnyro/clock/presentation/screens/alarm/components/ScrollAlarmTimePicker.kt +++ b/app/src/main/java/com/bnyro/clock/presentation/screens/alarm/components/ScrollAlarmTimePicker.kt @@ -11,14 +11,9 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.input.nestedscroll.NestedScrollConnection -import androidx.compose.ui.input.nestedscroll.NestedScrollSource -import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.LayoutDirection -import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp import com.bnyro.clock.presentation.components.ScrollWheel @@ -35,25 +30,8 @@ fun ScrollAlarmTimePicker( // Track AM/PM state dynamically based on incoming hours val meridiem = if (initialHours >= 12) Meridiem.PM else Meridiem.AM - // the wheels sit on a page that scrolls the same way they do, so a drag that - // lands on them is theirs alone and never reaches the page behind - val keepDragsOnTheWheels = remember { - object : NestedScrollConnection { - override fun onPostScroll( - consumed: Offset, - available: Offset, - source: NestedScrollSource - ) = available.copy(x = 0f) - - override suspend fun onPostFling(consumed: Velocity, available: Velocity) = - available.copy(x = 0f) - } - } - Box( - modifier = Modifier - .fillMaxWidth() - .nestedScroll(keepDragsOnTheWheels), + modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center ) { CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { diff --git a/app/src/main/java/com/bnyro/clock/presentation/screens/ringing/RingingActivity.kt b/app/src/main/java/com/bnyro/clock/presentation/screens/ringing/RingingActivity.kt new file mode 100644 index 000000000..5499ede56 --- /dev/null +++ b/app/src/main/java/com/bnyro/clock/presentation/screens/ringing/RingingActivity.kt @@ -0,0 +1,167 @@ +package com.bnyro.clock.presentation.screens.ringing + +import android.app.KeyguardManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager +import android.media.AudioManager +import android.os.Build +import android.os.Bundle +import android.view.KeyEvent +import android.view.Window +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.activity.enableEdgeToEdge +import androidx.core.content.ContextCompat +import com.bnyro.clock.domain.model.VolumeButtonAction +import com.bnyro.clock.util.Preferences + +/** + * The screen an alarm or a timer takes over the phone with while it rings: it shows over the lock + * screen, wakes the display, keeps it awake, and answers the phone put face down or the volume keys + * the way the reader asked for. What it rings for, and what dismissing or snoozing it means, belongs + * to whoever it is showing. + */ +abstract class RingingActivity : ComponentActivity() { + /** The broadcast the service sends once the thing being shown has stopped ringing. */ + protected abstract val closeAction: String + + /** The preference naming what the volume keys do while this screen is up. */ + protected abstract val volumeButtonActionKey: String + + /** What the volume keys do while that preference has never been set. */ + protected open val volumeButtonActionDefault = VolumeButtonAction.SNOOZE + + protected abstract fun dismiss() + + protected abstract fun snooze() + + /** Whether snoozing is offered at all, which the volume keys fall back from when it is not. */ + protected open val snoozeAvailable: Boolean get() = true + + /** Whether a close broadcast is about what this screen is currently showing. */ + protected open fun closesThisAlert(intent: Intent) = true + + private val sensorManager: SensorManager by lazy { + getSystemService(Context.SENSOR_SERVICE) as SensorManager + } + private val gravitySensor: Sensor? by lazy { + sensorManager.getDefaultSensor(Sensor.TYPE_GRAVITY) + } + private var facingDownInitially: Boolean? = null + + private val closeAlertReciever = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.getStringExtra(ACTION_EXTRA_KEY) != CLOSE_ACTION) return + if (closesThisAlert(intent)) finish() + } + } + + private val sensorEventListener = object : SensorEventListener { + override fun onSensorChanged(event: SensorEvent) { + val gravityThreshold = SensorManager.GRAVITY_EARTH * 0.95f + + if (event.sensor.type == Sensor.TYPE_GRAVITY) { + val isDown = event.values[2] < -gravityThreshold + if (facingDownInitially == null) { + facingDownInitially = isDown + return + } + if (isDown && facingDownInitially != true) { + dismiss() + } + } + } + + override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + volumeControlStream = AudioManager.STREAM_ALARM + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + setShowWhenLocked(true) + setTurnScreenOn(true) + + val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager + keyguardManager.requestDismissKeyguard(this, null) + } else { + @Suppress("DEPRECATION") + window.addFlags( + WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or + WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD or + WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON + ) + } + + window.addFlags( + WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or + WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON + ) + + requestWindowFeature(Window.FEATURE_NO_TITLE) + + ContextCompat.registerReceiver( + this, + closeAlertReciever, + IntentFilter(closeAction), + ContextCompat.RECEIVER_NOT_EXPORTED + ) + + enableEdgeToEdge() + } + + override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { + if (keyCode != KeyEvent.KEYCODE_VOLUME_DOWN && keyCode != KeyEvent.KEYCODE_VOLUME_UP) { + return super.onKeyDown(keyCode, event) + } + + return when ( + VolumeButtonAction.valueOf( + Preferences.instance.getString( + volumeButtonActionKey, + volumeButtonActionDefault.name + ) ?: volumeButtonActionDefault.name + ) + ) { + VolumeButtonAction.SNOOZE -> { + if (snoozeAvailable) snooze() else dismiss() + true + } + VolumeButtonAction.DISMISS -> { + dismiss() + true + } + VolumeButtonAction.CONTROL_VOLUME -> super.onKeyDown(keyCode, event) + VolumeButtonAction.DO_NOTHING -> true + } + } + + override fun onResume() { + super.onResume() + gravitySensor?.let { + sensorManager.registerListener(sensorEventListener, it, SensorManager.SENSOR_DELAY_NORMAL) + } + } + + override fun onPause() { + super.onPause() + sensorManager.unregisterListener(sensorEventListener) + } + + override fun onDestroy() { + unregisterReceiver(closeAlertReciever) + super.onDestroy() + } + + companion object { + const val ACTION_EXTRA_KEY = "action" + const val CLOSE_ACTION = "CLOSE" + } +} diff --git a/app/src/main/java/com/bnyro/clock/presentation/screens/ringing/RingingAlert.kt b/app/src/main/java/com/bnyro/clock/presentation/screens/ringing/RingingAlert.kt new file mode 100644 index 000000000..a5598c1d6 --- /dev/null +++ b/app/src/main/java/com/bnyro/clock/presentation/screens/ringing/RingingAlert.kt @@ -0,0 +1,170 @@ +package com.bnyro.clock.presentation.screens.ringing + +import android.content.res.Configuration.ORIENTATION_PORTRAIT +import androidx.compose.animation.core.Ease +import androidx.compose.animation.core.EaseInOutBack +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.bnyro.clock.presentation.screens.settings.model.SettingsModel +import com.bnyro.clock.ui.theme.ClockYouTheme +import com.bnyro.clock.util.ThemeUtil +import com.bnyro.clock.util.TimeHelper +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import java.time.ZonedDateTime + +/** + * The shape every ringing screen takes: a dark page carrying the icon of whatever is ringing beside + * the controls that answer it, side by side when the phone is on its side. + */ +@Composable +fun RingingAlert(icon: Painter, controls: @Composable ColumnScope.() -> Unit) { + val settingsModel: SettingsModel = viewModel() + ClockYouTheme( + darkTheme = true, + customColorScheme = ThemeUtil.getSchemeFromSeed( + settingsModel.customColor, + true + ) + ) { + val orientation = LocalConfiguration.current.orientation + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + if (orientation == ORIENTATION_PORTRAIT) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.SpaceEvenly, + horizontalAlignment = Alignment.CenterHorizontally + ) { + RingingIcon(icon) + controls() + } + } else { + Row { + Column( + modifier = Modifier + .fillMaxHeight() + .weight(2f), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + RingingIcon(icon) + } + + Column( + modifier = Modifier + .fillMaxHeight() + .weight(3f), + verticalArrangement = Arrangement.SpaceEvenly, + horizontalAlignment = Alignment.CenterHorizontally, + content = controls + ) + } + } + } + } +} + +/** + * The time a ringing screen leads with, which is the time an alarm was set for and the moment a + * timer finished, over the name of whatever is ringing, which a reader woken by it reads first. + */ +@Composable +fun RingingTitle(label: String?, showSeconds: Boolean = false, time: ZonedDateTime? = null) { + val context = LocalContext.current + val shownTime = if (time != null) { + TimeHelper.formatTime(context, time, showSeconds) + } else { + val now by produceState( + initialValue = TimeHelper.formatTime( + context, + TimeHelper.getTimeByZone(), + showSeconds + ), + showSeconds + ) { + while (isActive) { + value = TimeHelper.formatTime( + context, + TimeHelper.getTimeByZone(), + showSeconds + ) + delay(1000) + } + } + now + } + Text( + text = shownTime, + style = MaterialTheme.typography.displayMedium + ) + label?.let { + Text(text = it, style = MaterialTheme.typography.headlineMedium) + } +} + +@Composable +private fun RingingIcon(icon: Painter) { + val infiniteTransition = rememberInfiniteTransition(label = "") + val rotation by infiniteTransition.animateFloat( + initialValue = -10F, + targetValue = 10F, + animationSpec = infiniteRepeatable( + animation = tween(400, easing = EaseInOutBack), + repeatMode = RepeatMode.Reverse + ), + label = "" + ) + val offset by infiniteTransition.animateFloat( + initialValue = 10F, + targetValue = -10F, + animationSpec = infiniteRepeatable( + animation = tween(200, easing = Ease), + repeatMode = RepeatMode.Reverse + ), + label = "" + ) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .offset(y = offset.dp) + .rotate(rotation) + ) { + Image( + modifier = Modifier.size(250.dp), + painter = icon, + contentDescription = null, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary) + ) + } +} diff --git a/app/src/main/java/com/bnyro/clock/presentation/screens/settings/SettingsScreen.kt b/app/src/main/java/com/bnyro/clock/presentation/screens/settings/SettingsScreen.kt index c619ceab2..fb80e62f5 100644 --- a/app/src/main/java/com/bnyro/clock/presentation/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/com/bnyro/clock/presentation/screens/settings/SettingsScreen.kt @@ -10,11 +10,13 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.VolumeUp import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.filled.Backup import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.Restore +import androidx.compose.material.icons.rounded.MoreTime import androidx.compose.material.icons.rounded.Timer import androidx.compose.material.icons.rounded.Widgets import androidx.compose.material3.ExperimentalMaterial3Api @@ -41,6 +43,7 @@ import androidx.compose.ui.unit.dp import com.bnyro.clock.BuildConfig import com.bnyro.clock.R import com.bnyro.clock.domain.model.PickerStyle +import com.bnyro.clock.domain.model.TimerPickerBehaviour import com.bnyro.clock.domain.model.VolumeButtonAction import com.bnyro.clock.domain.model.WeekStart import com.bnyro.clock.navigation.NavRoutes @@ -53,17 +56,16 @@ import com.bnyro.clock.presentation.screens.settings.components.IconPreference import com.bnyro.clock.presentation.screens.settings.components.SettingsCategory import com.bnyro.clock.presentation.screens.settings.components.SwitchPref import com.bnyro.clock.presentation.screens.settings.model.SettingsModel -import com.bnyro.clock.presentation.screens.timer.model.TimerModel import com.bnyro.clock.util.Preferences import com.bnyro.clock.util.services.AlarmService +import com.bnyro.clock.util.services.TimerService @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun SettingsScreen( onClickBack: () -> Unit, onNavigate: (String) -> Unit, - settingsModel: SettingsModel, - timerModel: TimerModel + settingsModel: SettingsModel ) { val context = LocalContext.current val scrollState = rememberScrollState() @@ -71,6 +73,15 @@ fun SettingsScreen( rememberTopAppBarState() ) var showAlarmTimeoutDialog by remember { mutableStateOf(false) } + var showTimerTimeoutDialog by remember { mutableStateOf(false) } + var showTimerIncrementDialog by remember { mutableStateOf(false) } + var showAlarmVolumeRampDialog by remember { mutableStateOf(false) } + var showTimerVolumeRampDialog by remember { mutableStateOf(false) } + var timerIncrementSeconds by remember { + mutableIntStateOf( + Preferences.instance.getInt(Preferences.timerIncrementSecondsKey, 60) + ) + } var alarmTimeoutMinutes by remember { mutableIntStateOf( Preferences.instance.getInt( @@ -79,6 +90,24 @@ fun SettingsScreen( ) ) } + var timerTimeoutMinutes by remember { + mutableIntStateOf( + Preferences.instance.getInt( + Preferences.timerTimeoutMinutesKey, + TimerService.TIMER_TIMEOUT_MINUTES + ) + ) + } + var alarmVolumeRampSeconds by remember { + mutableIntStateOf( + Preferences.instance.getInt(Preferences.alarmVolumeRampSecondsKey, 0) + ) + } + var timerVolumeRampSeconds by remember { + mutableIntStateOf( + Preferences.instance.getInt(Preferences.timerVolumeRampSecondsKey, 0) + ) + } val documentPickerLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.OpenDocument(), @@ -264,6 +293,14 @@ fun SettingsScreen( showAlarmTimeoutDialog = true } + IconPreference( + title = stringResource(R.string.volume_ramp), + summary = volumeRampSummary(alarmVolumeRampSeconds), + imageVector = Icons.AutoMirrored.Rounded.VolumeUp + ) { + showAlarmVolumeRampDialog = true + } + HorizontalDivider( modifier = Modifier.padding(top = 12.dp, bottom = 8.dp), color = MaterialTheme.colorScheme.surfaceVariant @@ -300,22 +337,88 @@ fun SettingsScreen( ) { settingsModel.timerPickerStyle = it Preferences.edit { putString(Preferences.timerPickerStyleKey, it.name) } - timerModel.timePickerFakeUnits = 0 - timerModel.timePickerSeconds = 0 } - SwitchPref( - prefKey = Preferences.timerShowExamplesKey, - title = stringResource(R.string.show_timer_quick_selection), - defaultValue = true - ) + ButtonGroupPref( + title = stringResource(R.string.volume_buttons_during_timer), + options = listOf( + stringResource(R.string.snooze), + stringResource(R.string.dismiss), + stringResource(R.string.control_volume), + stringResource(R.string.do_nothing) + ), + values = VolumeButtonAction.entries, + currentValue = settingsModel.timerVolumeButtonAction + ) { action -> + settingsModel.timerVolumeButtonAction = action + Preferences.edit { + putString(Preferences.timerVolumeButtonActionKey, action.name) + } + } + + ButtonGroupPref( + title = stringResource(R.string.timer_picker_behaviour), + options = TimerPickerBehaviour.entries.map { + stringResource( + when (it) { + TimerPickerBehaviour.HIDE -> R.string.picker_hide + TimerPickerBehaviour.KEEP_OPEN -> R.string.picker_keep_open + } + ) + }, + values = TimerPickerBehaviour.entries, + currentValue = settingsModel.timerPickerBehaviour + ) { + settingsModel.timerPickerBehaviour = it + Preferences.edit { putString(Preferences.timerPickerBehaviourKey, it.name) } + } SwitchPref( - prefKey = "timer_BIG_start_button", + prefKey = Preferences.timerBigStartButtonKey, title = stringResource(R.string.timer_use_big_start), defaultValue = false + ) { + settingsModel.timerBigStartButton = it + } + + SwitchPref( + prefKey = Preferences.timerFullScreenAlertKey, + title = stringResource(R.string.timer_full_screen_alert), + defaultValue = true ) + IconPreference( + title = stringResource(R.string.timer_increment), + summary = pluralStringResource( + R.plurals.seconds_count, + timerIncrementSeconds, + timerIncrementSeconds + ), + imageVector = Icons.Rounded.MoreTime + ) { + showTimerIncrementDialog = true + } + + IconPreference( + title = stringResource(R.string.timeout_after), + summary = pluralStringResource( + R.plurals.minutes, + timerTimeoutMinutes, + timerTimeoutMinutes + ), + imageVector = Icons.Rounded.Timer + ) { + showTimerTimeoutDialog = true + } + + IconPreference( + title = stringResource(R.string.volume_ramp), + summary = volumeRampSummary(timerVolumeRampSeconds), + imageVector = Icons.AutoMirrored.Rounded.VolumeUp + ) { + showTimerVolumeRampDialog = true + } + HorizontalDivider( modifier = Modifier.padding(top = 12.dp, bottom = 8.dp), color = MaterialTheme.colorScheme.surfaceVariant @@ -382,6 +485,22 @@ fun SettingsScreen( ) } } + if (showTimerIncrementDialog) { + ScrollPickerDialog( + onDismissRequest = { showTimerIncrementDialog = false }, + title = stringResource(R.string.select_timer_increment), + unit = stringResource(R.string.seconds), + value = timerIncrementSeconds, + maxValue = 60, + offset = 1, + label = { it.toString() }, + onValueSet = { + timerIncrementSeconds = it + Preferences.edit { putInt(Preferences.timerIncrementSecondsKey, it) } + showTimerIncrementDialog = false + } + ) + } if (showAlarmTimeoutDialog) { ScrollPickerDialog( onDismissRequest = { showAlarmTimeoutDialog = false }, @@ -398,4 +517,63 @@ fun SettingsScreen( } ) } + if (showTimerTimeoutDialog) { + ScrollPickerDialog( + onDismissRequest = { showTimerTimeoutDialog = false }, + title = stringResource(R.string.select_timer_timeout), + unit = stringResource(R.string.minutes), + value = timerTimeoutMinutes, + maxValue = 120, + offset = 1, + label = { it.toString() }, + onValueSet = { + timerTimeoutMinutes = it + Preferences.edit { putInt(Preferences.timerTimeoutMinutesKey, it) } + showTimerTimeoutDialog = false + } + ) + } + if (showAlarmVolumeRampDialog) { + ScrollPickerDialog( + onDismissRequest = { showAlarmVolumeRampDialog = false }, + title = stringResource(R.string.select_volume_ramp), + unit = stringResource(R.string.seconds), + value = alarmVolumeRampSeconds, + maxValue = 61, + offset = 0, + label = { it.toString() }, + onValueSet = { + alarmVolumeRampSeconds = it + Preferences.edit { putInt(Preferences.alarmVolumeRampSecondsKey, it) } + showAlarmVolumeRampDialog = false + } + ) + } + if (showTimerVolumeRampDialog) { + ScrollPickerDialog( + onDismissRequest = { showTimerVolumeRampDialog = false }, + title = stringResource(R.string.select_volume_ramp), + unit = stringResource(R.string.seconds), + value = timerVolumeRampSeconds, + maxValue = 61, + offset = 0, + label = { it.toString() }, + onValueSet = { + timerVolumeRampSeconds = it + Preferences.edit { putInt(Preferences.timerVolumeRampSecondsKey, it) } + showTimerVolumeRampDialog = false + } + ) + } +} + +/** + * A rise of no seconds at all is the sound arriving at once, which reads as never rather than as + * zero seconds of rising. + */ +@Composable +private fun volumeRampSummary(seconds: Int) = if (seconds == 0) { + stringResource(R.string.volume_ramp_never) +} else { + pluralStringResource(R.plurals.seconds_count, seconds, seconds) } diff --git a/app/src/main/java/com/bnyro/clock/presentation/screens/settings/components/SettingsCategory.kt b/app/src/main/java/com/bnyro/clock/presentation/screens/settings/components/SettingsCategory.kt index 5a49d37d2..f3a5ec269 100644 --- a/app/src/main/java/com/bnyro/clock/presentation/screens/settings/components/SettingsCategory.kt +++ b/app/src/main/java/com/bnyro/clock/presentation/screens/settings/components/SettingsCategory.kt @@ -1,27 +1,33 @@ package com.bnyro.clock.presentation.screens.settings.components import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @Composable fun SettingsCategory( - title: String + title: String, + actions: @Composable (RowScope.() -> Unit) = {} ) { Row( modifier = Modifier .fillMaxWidth() - .padding(top = 16.dp) + .padding(top = 16.dp), + verticalAlignment = Alignment.CenterVertically ) { Text( text = title.uppercase(), + modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary ) + actions() } } diff --git a/app/src/main/java/com/bnyro/clock/presentation/screens/settings/model/SettingsModel.kt b/app/src/main/java/com/bnyro/clock/presentation/screens/settings/model/SettingsModel.kt index 761e00802..4c6365810 100644 --- a/app/src/main/java/com/bnyro/clock/presentation/screens/settings/model/SettingsModel.kt +++ b/app/src/main/java/com/bnyro/clock/presentation/screens/settings/model/SettingsModel.kt @@ -17,6 +17,7 @@ import com.bnyro.clock.App import com.bnyro.clock.R import com.bnyro.clock.domain.model.Alarm import com.bnyro.clock.domain.model.PickerStyle +import com.bnyro.clock.domain.model.TimerPickerBehaviour import com.bnyro.clock.domain.model.WeekStart import com.bnyro.clock.domain.usecase.CreateUpdateDeleteAlarmUseCase import com.bnyro.clock.navigation.HomeRoutes @@ -62,6 +63,17 @@ class SettingsModel : ViewModel() { ) ?: PickerStyle.WHEEL.name ) ) + var timerPickerBehaviour by mutableStateOf( + TimerPickerBehaviour.valueOf( + Preferences.instance.getString( + Preferences.timerPickerBehaviourKey, + TimerPickerBehaviour.HIDE.name + ) ?: TimerPickerBehaviour.HIDE.name + ) + ) + var timerBigStartButton by mutableStateOf( + Preferences.instance.getBoolean(Preferences.timerBigStartButtonKey, false) + ) var alarmPickerStyle by mutableStateOf( PickerStyle.valueOf( Preferences.instance.getString( @@ -113,6 +125,15 @@ class SettingsModel : ViewModel() { ) ) + var timerVolumeButtonAction by mutableStateOf( + VolumeButtonAction.valueOf( + Preferences.instance.getString( + Preferences.timerVolumeButtonActionKey, + VolumeButtonAction.DISMISS.name + ) ?: VolumeButtonAction.DISMISS.name + ) + ) + fun updateFabAlignment(alignment: FabAlignment) { Preferences.edit { putString("fab_alignment", alignment.name) } fabAlignment = alignment diff --git a/app/src/main/java/com/bnyro/clock/presentation/screens/stopwatch/StopwatchScreen.kt b/app/src/main/java/com/bnyro/clock/presentation/screens/stopwatch/StopwatchScreen.kt index f5c3ecaa2..99a8d8164 100644 --- a/app/src/main/java/com/bnyro/clock/presentation/screens/stopwatch/StopwatchScreen.kt +++ b/app/src/main/java/com/bnyro/clock/presentation/screens/stopwatch/StopwatchScreen.kt @@ -6,6 +6,9 @@ import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.LayoutDirection import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -47,18 +50,25 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex import com.bnyro.clock.R import com.bnyro.clock.domain.model.WatchState import com.bnyro.clock.navigation.TopBarScaffold import com.bnyro.clock.presentation.screens.stopwatch.model.StopwatchModel +import com.bnyro.clock.ui.theme.ItemFade +import com.bnyro.clock.ui.theme.ItemSlide +import com.bnyro.clock.ui.theme.ListResize import com.bnyro.clock.util.extensions.KeepScreenOn import com.bnyro.clock.util.extensions.addZero import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +private val SIDE_CONTROL_SLOT = 76.dp + @Composable fun StopwatchScreen(onClickSettings: () -> Unit, stopwatchModel: StopwatchModel) { val context = LocalContext.current @@ -147,25 +157,30 @@ private fun StopwatchController( horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically ) { - AnimatedVisibility(stopwatchModel.state == WatchState.RUNNING) { - Row { - FloatingActionButton( - containerColor = MaterialTheme.colorScheme.tertiaryContainer, - onClick = { - stopwatchModel.onLapClicked() + SideControl( + visible = stopwatchModel.state == WatchState.RUNNING, + alignment = Alignment.CenterStart + ) { + FloatingActionButton( + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + onClick = { + val tableOverflows = timeStampsState.canScrollForward || + timeStampsState.canScrollBackward + stopwatchModel.onLapClicked() + if (tableOverflows) { scope.launch { timeStampsState.scrollToItem( stopwatchModel.rememberedTimeStamps.size - 1 ) } } - ) { - Icon(Icons.Default.Timer, null) } - Spacer(modifier = Modifier.width(20.dp)) + ) { + Icon(Icons.Default.Timer, null) } } LargeFloatingActionButton( + modifier = Modifier.zIndex(1f), shape = CircleShape, onClick = { stopwatchModel.pauseResumeStopwatch(context) @@ -180,33 +195,60 @@ private fun StopwatchController( contentDescription = null ) } - AnimatedVisibility(stopwatchModel.currentPosition != 0L) { - Row { - Spacer(modifier = Modifier.width(20.dp)) - if (stopwatchModel.state != WatchState.PAUSED) { - FloatingActionButton( - containerColor = MaterialTheme.colorScheme.tertiaryContainer, - onClick = { stopwatchModel.stopStopwatch(context) } - ) { - Icon(Icons.Default.Stop, null) - } - } else { - FloatingActionButton( - containerColor = MaterialTheme.colorScheme.errorContainer, - contentColor = MaterialTheme.colorScheme.onErrorContainer, - onClick = { - stopwatchModel.stopStopwatch(context) - stopwatchModel.rememberedTimeStamps.clear() - } - ) { - Icon(Icons.Default.Delete, null) + SideControl( + visible = stopwatchModel.currentPosition != 0L, + alignment = Alignment.CenterEnd + ) { + if (stopwatchModel.state != WatchState.PAUSED) { + FloatingActionButton( + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + onClick = { stopwatchModel.stopStopwatch(context) } + ) { + Icon(Icons.Default.Stop, null) + } + } else { + FloatingActionButton( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + onClick = { + stopwatchModel.stopStopwatch(context) + stopwatchModel.rememberedTimeStamps.clear() } + ) { + Icon(Icons.Default.Delete, null) } } } } } +@Composable +private fun SideControl( + visible: Boolean, + alignment: Alignment, + content: @Composable () -> Unit +) { + val slotWidth = with(LocalDensity.current) { SIDE_CONTROL_SLOT.roundToPx() } + val behindMainControl = if (alignment == Alignment.CenterStart) { + slotWidth + } else { + -slotWidth + } + + Box( + modifier = Modifier.width(SIDE_CONTROL_SLOT), + contentAlignment = alignment + ) { + AnimatedVisibility( + visible = visible, + enter = slideInHorizontally(ItemSlide) { behindMainControl }, + exit = slideOutHorizontally(ItemSlide) { behindMainControl } + ) { + content() + } + } +} + @Composable private fun LapTable( modifier: Modifier = Modifier, @@ -219,10 +261,11 @@ private fun LapTable( RoundedCornerShape(16.dp) ) .background(MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)) + .animateContentSize(ListResize) .padding(16.dp), state = timeStampsState ) { - item { + item(key = "header") { Column { Row { Text( @@ -247,9 +290,13 @@ private fun LapTable( HorizontalDivider() } } - itemsIndexed(stopwatchModel.rememberedTimeStamps) { index, time -> + itemsIndexed( + items = stopwatchModel.rememberedTimeStamps, + key = { index, _ -> index } + ) { index, time -> Row( modifier = Modifier + .animateItem(ItemFade, ItemSlide, ItemFade) .padding(vertical = 6.dp) ) { Text( diff --git a/app/src/main/java/com/bnyro/clock/presentation/screens/timer/TimerAlertActivity.kt b/app/src/main/java/com/bnyro/clock/presentation/screens/timer/TimerAlertActivity.kt new file mode 100644 index 000000000..b8f444794 --- /dev/null +++ b/app/src/main/java/com/bnyro/clock/presentation/screens/timer/TimerAlertActivity.kt @@ -0,0 +1,88 @@ +package com.bnyro.clock.presentation.screens.timer + +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.bnyro.clock.domain.model.VolumeButtonAction +import com.bnyro.clock.presentation.screens.ringing.RingingActivity +import com.bnyro.clock.util.Preferences +import com.bnyro.clock.util.services.TimerService + +class TimerAlertActivity : RingingActivity() { + private var timerId by mutableIntStateOf(0) + private var label by mutableStateOf(null) + private var ringingSince by mutableLongStateOf(0L) + private var incrementSeconds by mutableIntStateOf(60) + + override val closeAction = TimerService.TIMER_ALERT_CLOSE_ACTION + + override val volumeButtonActionKey = Preferences.timerVolumeButtonActionKey + + override val volumeButtonActionDefault = VolumeButtonAction.DISMISS + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + handleIntent(intent) + + setContent { + TimerAlertScreen( + onDismiss = this@TimerAlertActivity::dismiss, + onSnooze = this@TimerAlertActivity::snooze, + onReset = this@TimerAlertActivity::reset, + label = label, + ringingSince = ringingSince, + incrementSeconds = incrementSeconds + ) + } + } + + override fun dismiss() = answerWith(TimerService.ACTION_STOP) + + override fun snooze() = answerWith(TimerService.ACTION_ADD_TIME) + + private fun reset() = answerWith(TimerService.TIMER_RESTART) + + private fun answerWith(action: String) { + sendBroadcast(TimerService.updateStateIntent(action, timerId)) + finish() + } + + override fun closesThisAlert(intent: Intent) = + intent.getIntExtra(TimerService.ID_EXTRA_KEY, 0) == timerId + + override fun onStart() { + super.onStart() + reportAlert(TimerService.ACTION_ALERT_SHOWN) + } + + override fun onStop() { + reportAlert(TimerService.ACTION_ALERT_HIDDEN) + super.onStop() + } + + private fun reportAlert(action: String) { + sendBroadcast(TimerService.updateStateIntent(action, timerId)) + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + handleIntent(intent) + // a takeover hands the showing screen to another timer, which counts as showing it anew + reportAlert(TimerService.ACTION_ALERT_SHOWN) + } + + private fun handleIntent(intent: Intent) { + timerId = intent.getIntExtra(TimerService.ID_EXTRA_KEY, 0) + label = intent.getStringExtra(TimerService.LABEL_EXTRA_KEY) + ringingSince = intent.getLongExtra( + TimerService.RINGING_SINCE_EXTRA_KEY, + System.currentTimeMillis() + ) + incrementSeconds = intent.getIntExtra(TimerService.INCREMENT_EXTRA_KEY, 60) + } +} diff --git a/app/src/main/java/com/bnyro/clock/presentation/screens/timer/TimerAlertScreen.kt b/app/src/main/java/com/bnyro/clock/presentation/screens/timer/TimerAlertScreen.kt new file mode 100644 index 000000000..79c98f4a9 --- /dev/null +++ b/app/src/main/java/com/bnyro/clock/presentation/screens/timer/TimerAlertScreen.kt @@ -0,0 +1,164 @@ +package com.bnyro.clock.presentation.screens.timer + +import android.content.res.Configuration +import android.text.format.DateUtils +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material.icons.rounded.Timer +import androidx.compose.material.icons.rounded.TimerOff +import androidx.compose.material3.Button +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.bnyro.clock.R +import com.bnyro.clock.presentation.screens.ringing.RingingAlert +import com.bnyro.clock.presentation.screens.ringing.RingingTitle +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import java.time.Instant +import java.time.ZoneId + +@Composable +fun TimerAlertScreen( + onDismiss: () -> Unit, + onSnooze: () -> Unit, + onReset: () -> Unit, + label: String? = null, + ringingSince: Long, + incrementSeconds: Int +) { + RingingAlert(rememberVectorPainter(Icons.Rounded.Timer)) { + TimerAlertControls(label, ringingSince, incrementSeconds, onDismiss, onSnooze, onReset) + } +} + +@Composable +private fun TimerAlertControls( + label: String?, + ringingSince: Long, + incrementSeconds: Int, + onDismiss: () -> Unit, + onSnooze: () -> Unit, + onReset: () -> Unit +) { + RingingTitle( + label, + showSeconds = true, + time = Instant.ofEpochMilli(ringingSince).atZone(ZoneId.systemDefault()) + ) + + // the timer does not stop at zero, it goes on counting the wait for an answer + val rung by produceState(initialValue = 0L, ringingSince) { + while (isActive) { + value = (System.currentTimeMillis() - ringingSince) / 1000 + delay(1000) + } + } + Text( + text = "-" + DateUtils.formatElapsedTime(rung), + style = MaterialTheme.typography.headlineMedium + ) + Column( + Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Button( + onClick = { + onDismiss.invoke() + } + ) { + Row(Modifier.padding(8.dp)) { + Icon( + modifier = Modifier.align(alignment = Alignment.CenterVertically), + imageVector = Icons.Rounded.TimerOff, + contentDescription = null + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.dismiss), + style = MaterialTheme.typography.titleLarge + ) + } + } + Spacer(modifier = Modifier.height(32.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + FilledTonalButton( + onClick = { + onSnooze.invoke() + } + ) { + Text( + text = if (incrementSeconds == 60) { + stringResource(R.string.add_one_minute) + } else { + pluralStringResource( + R.plurals.add_seconds, + incrementSeconds, + incrementSeconds + ) + }, + style = MaterialTheme.typography.titleLarge + ) + } + FilledTonalButton( + onClick = { + onReset.invoke() + } + ) { + Row { + Icon( + modifier = Modifier.align(alignment = Alignment.CenterVertically), + imageVector = Icons.Rounded.Refresh, + contentDescription = null + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.timer_reset), + style = MaterialTheme.typography.titleLarge + ) + } + } + } + } +} + +@Preview( + showBackground = true, + uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL, + device = "spec:width=411dp,height=891dp", + showSystemUi = true +) +@Composable +private fun DefaultPreview() { + TimerAlertScreen( + onDismiss = {}, + onSnooze = {}, + onReset = {}, + label = "Pasta", + ringingSince = System.currentTimeMillis(), + incrementSeconds = 60 + ) +} diff --git a/app/src/main/java/com/bnyro/clock/presentation/screens/timer/TimerScreen.kt b/app/src/main/java/com/bnyro/clock/presentation/screens/timer/TimerScreen.kt index 4de7d24ec..afe8651cf 100644 --- a/app/src/main/java/com/bnyro/clock/presentation/screens/timer/TimerScreen.kt +++ b/app/src/main/java/com/bnyro/clock/presentation/screens/timer/TimerScreen.kt @@ -1,491 +1,322 @@ package com.bnyro.clock.presentation.screens.timer -import android.content.Context -import android.content.res.Configuration -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.sizeIn -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.rounded.AddAlarm -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.FloatingActionButton +import androidx.compose.material.icons.rounded.ExpandLess +import androidx.compose.material.icons.rounded.ExpandMore +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.LargeFloatingActionButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.bnyro.clock.R -import com.bnyro.clock.domain.model.NumberKeypadOperation -import com.bnyro.clock.domain.model.PickerStyle +import com.bnyro.clock.domain.model.TimerObject +import com.bnyro.clock.domain.model.TimerPickerBehaviour +import com.bnyro.clock.domain.model.TimerSettings import com.bnyro.clock.navigation.TopBarScaffold import com.bnyro.clock.presentation.components.ClickableIcon -import com.bnyro.clock.presentation.components.ClockTimePicker -import com.bnyro.clock.presentation.components.ScrollTimerPicker +import com.bnyro.clock.presentation.components.DialogButton +import com.bnyro.clock.presentation.components.DialogButtonStyle +import com.bnyro.clock.presentation.screens.settings.components.SettingsCategory import com.bnyro.clock.presentation.screens.settings.model.SettingsModel -import com.bnyro.clock.presentation.screens.timer.components.FormattedTimerTime -import com.bnyro.clock.presentation.screens.timer.components.NumberKeypad +import com.bnyro.clock.presentation.screens.timer.components.SavedTimerItem +import com.bnyro.clock.presentation.screens.timer.components.TimerEditSheet import com.bnyro.clock.presentation.screens.timer.components.TimerItem +import com.bnyro.clock.presentation.screens.timer.components.TimerPickerSelector import com.bnyro.clock.presentation.screens.timer.model.TimerModel -import com.bnyro.clock.util.Preferences +import com.bnyro.clock.ui.theme.ItemFade +import com.bnyro.clock.ui.theme.ItemFadeDurationMillis +import com.bnyro.clock.ui.theme.ItemSlide import com.bnyro.clock.util.extensions.KeepScreenOn +import kotlinx.coroutines.delay -@OptIn(ExperimentalMaterial3Api::class) @Composable fun TimerScreen( onClickSettings: () -> Unit, timerModel: TimerModel, settingsModel: SettingsModel ) { val context = LocalContext.current - val showExampleTimers = Preferences.instance.getBoolean(Preferences.timerShowExamplesKey, true) - val usebigassStartButton = Preferences.instance.getBoolean("timer_BIG_start_button", false) - - var createNew by remember { - mutableStateOf(false) - } + val activeTimers by timerModel.scheduledObjects.collectAsState() + var editedTimer by remember { mutableStateOf(null) } + var editedSavedTimerId by remember { mutableStateOf(null) } + // the picker has nowhere to go until something is running, and how it starts out + // once something is is what the setting decides rather than where it was left + var showPicker by remember(settingsModel.timerPickerBehaviour) { + mutableStateOf( + activeTimers.isEmpty() || + settingsModel.timerPickerBehaviour == TimerPickerBehaviour.KEEP_OPEN + ) + } + LaunchedEffect(activeTimers.isEmpty(), settingsModel.timerPickerBehaviour) { + if (activeTimers.isEmpty()) { + showPicker = true + } else if (settingsModel.timerPickerBehaviour == TimerPickerBehaviour.HIDE) { + delay(ItemFadeDurationMillis.toLong()) + showPicker = false + } + } - var selectedPresets by remember { mutableStateOf(setOf()) } - - val scheduledObjects by timerModel.scheduledObjects.collectAsState() - val screenTitle = if (selectedPresets.isNotEmpty()) { - "${selectedPresets.size} Selected" - } else { - stringResource(R.string.timer) + val timerPageState = rememberLazyListState() + LaunchedEffect(showPicker) { + if (showPicker) timerPageState.animateScrollToItem(0) } + val selectedSavedTimerIds = remember { mutableStateListOf() } + val isSelectionMode = selectedSavedTimerIds.isNotEmpty() + var showDeletionDialog by remember { mutableStateOf(false) } + TopBarScaffold( - title = screenTitle, - onClickSettings = onClickSettings, - fabPosition = settingsModel.fabAlignment.position, + title = if (isSelectionMode) { + stringResource(R.string.selected_count, selectedSavedTimerIds.size) + } else { + stringResource(R.string.timer) + }, + onClickSettings = if (isSelectionMode) { + { selectedSavedTimerIds.clear() } + } else { + onClickSettings + }, actions = { - if (scheduledObjects.isEmpty() && showExampleTimers && selectedPresets.isEmpty()) { - ClickableIcon( - imageVector = Icons.Rounded.AddAlarm, - contentDescription = stringResource(R.string.add_preset_timer) - ) { - timerModel.addPersistentTimer(timerModel.timePickerSeconds) + if (isSelectionMode) { + ClickableIcon(imageVector = Icons.Default.ContentCopy) { + timerModel.savedTimers + .filter { selectedSavedTimerIds.contains(it.id) } + .forEach { timerModel.copySavedTimer(it) } + selectedSavedTimerIds.clear() } - } - }, - fab = { - if (scheduledObjects.isNotEmpty() && selectedPresets.isEmpty()) { - FloatingActionButton(onClick = { - createNew = true - }) { - Icon(Icons.Rounded.Add, contentDescription = null) + ClickableIcon(imageVector = Icons.Default.Delete) { + showDeletionDialog = true } - } - }) { paddingValues -> - if (scheduledObjects.isEmpty()) { - Column( - Modifier.padding(paddingValues) - ) { - TimerPicker( - pickerStyle = settingsModel.timerPickerStyle, - timerModel = timerModel, - showExampleTimers = showExampleTimers, - context = context, - onCreateNew = { createNew = false }, - showFAB = false, - useSimpleStartButton = usebigassStartButton, - selectedPresets = selectedPresets, - onSelectedPresetsChanged = { selectedPresets = it } - ) - } - } else { - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues), - verticalArrangement = Arrangement.Top - ) { - items(scheduledObjects, key = { it.id }) { obj -> - TimerItem(obj, timerModel) + ClickableIcon(imageVector = Icons.Default.Close) { + selectedSavedTimerIds.clear() } } - KeepScreenOn() } - } - - if (createNew) { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - ModalBottomSheet( - onDismissRequest = { createNew = false }, sheetState = sheetState - ) { - TimerPicker( - pickerStyle = settingsModel.timerPickerStyle, - timerModel = timerModel, - showExampleTimers = showExampleTimers, - context = context, - onCreateNew = { createNew = false }, - showFAB = false, - useSimpleStartButton = usebigassStartButton, - selectedPresets = selectedPresets, - onSelectedPresetsChanged = { selectedPresets = it } - ) - } - } -} - -@Composable -private fun TimerPicker( - pickerStyle: PickerStyle, - timerModel: TimerModel, - showExampleTimers: Boolean, - context: Context, - onCreateNew: () -> Unit, - showFAB: Boolean, - useSimpleStartButton: Boolean, - selectedPresets: Set, - onSelectedPresetsChanged: (Set) -> Unit -) { - val orientation = LocalConfiguration.current.orientation - if (orientation == Configuration.ORIENTATION_PORTRAIT) { - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Box( - Modifier.weight(1f) - ) { - TimerPickerSelector(pickerStyle, timerModel) - } - if (showExampleTimers) { - PresetTimers( - timerModel = timerModel, - onCreateNew = onCreateNew, - context = context, - selectedPresets = selectedPresets, - onSelectedPresetsChanged = onSelectedPresetsChanged - ) - } - if (selectedPresets.isNotEmpty()) { - SelectionActionButtons( - selectedPresets = selectedPresets, - timerModel = timerModel, - usebigassButtons = useSimpleStartButton, - clearSelection = { onSelectedPresetsChanged(emptySet()) } - ) - } else { - StartTimerButton(showFAB, onCreateNew, timerModel, context, useSimpleStartButton) - } - } - } else { - Row( - modifier = Modifier.fillMaxSize(), horizontalArrangement = Arrangement.Center + ) { paddingValues -> + LazyColumn( + state = timerPageState, + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentPadding = PaddingValues(bottom = 16.dp) ) { - Box( - Modifier - .fillMaxHeight() - .weight(1f) - ) { - TimerPickerSelector(pickerStyle, timerModel) + if (showPicker) { + item(key = "picker") { + Column(modifier = Modifier.animateItem(ItemFade, ItemSlide, ItemFade)) { + TimerPickerSelector( + pickerStyle = settingsModel.timerPickerStyle, + seconds = timerModel.timePickerSeconds, + onSecondsChanged = { timerModel.timePickerSeconds = it } + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + horizontalArrangement = Arrangement.Center + ) { + val startTimer = { + timerModel.startTimer( + context, + TimerSettings(seconds = timerModel.timePickerSeconds) + ) + } + if (settingsModel.timerBigStartButton) { + LargeFloatingActionButton( + shape = CircleShape, + onClick = startTimer + ) { + Icon(Icons.Default.PlayArrow, contentDescription = null) + } + } else { + FilledIconButton( + modifier = Modifier.size(48.dp), + onClick = startTimer + ) { + Icon(Icons.Default.PlayArrow, contentDescription = null) + } + } + } + } + } } - Column( - modifier = Modifier - .fillMaxHeight() - .weight(1f), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - if (showExampleTimers) { - PresetTimers( - timerModel = timerModel, - onCreateNew = onCreateNew, - context = context, - selectedPresets = selectedPresets, - onSelectedPresetsChanged = onSelectedPresetsChanged - ) + + if (activeTimers.isNotEmpty()) { + item(key = "activeTimers") { + Column( + modifier = Modifier + .animateItem(ItemFade, ItemSlide, ItemFade) + .padding(horizontal = 16.dp) + ) { + HorizontalDivider( + modifier = Modifier.padding(top = 12.dp, bottom = 8.dp), + color = MaterialTheme.colorScheme.surfaceVariant + ) + SettingsCategory( + pluralStringResource(R.plurals.active_timers, activeTimers.size) + ) { + ClickableIcon( + imageVector = if (showPicker) { + Icons.Rounded.ExpandLess + } else { + Icons.Rounded.ExpandMore + }, + contentDescription = stringResource( + if (showPicker) { + R.string.hide_timer_picker + } else { + R.string.show_timer_picker + } + ) + ) { + showPicker = !showPicker + } + } + } } - if (selectedPresets.isNotEmpty()) { - SelectionActionButtons( - selectedPresets = selectedPresets, + items(activeTimers, key = { it.id }) { timer -> + TimerItem( + obj = timer, timerModel = timerModel, - usebigassButtons = useSimpleStartButton, - clearSelection = { onSelectedPresetsChanged(emptySet()) } + onEdit = { editedTimer = timer }, + modifier = Modifier.animateItem(ItemFade, ItemSlide, ItemFade) ) - } else { - StartTimerButton(showFAB, onCreateNew, timerModel, context, useSimpleStartButton) } } - } - } -} - -@Composable -private fun ColumnScope.StartTimerButton( - showFAB: Boolean, - onCreateNew: () -> Unit, - timerModel: TimerModel, - context: Context, - usebigassStartButton: Boolean -) { - if (usebigassStartButton) { - Button( - modifier = Modifier - .padding(vertical = 16.dp) - .align(Alignment.CenterHorizontally) - .fillMaxWidth(0.76f) - .height(96.dp), - - contentPadding = PaddingValues(horizontal = 32.dp, vertical = 0.dp), - onClick = { - onCreateNew.invoke() - timerModel.startTimer(context) - } - ) { - Text( - text = stringResource(R.string.start), - style = MaterialTheme.typography.headlineLarge, - textAlign = TextAlign.Center - ) - } - } else { - Button( - modifier = Modifier - .padding(start = 16.dp, end = 16.dp, top = 24.dp, bottom = 16.dp) - .align(Alignment.CenterHorizontally) - .fillMaxWidth(0.55f) - .sizeIn(minHeight = 56.dp, maxHeight = 56.dp), - onClick = { - onCreateNew.invoke() - timerModel.startTimer(context) - } - ) { - Text( - text = stringResource(R.string.start), - style = MaterialTheme.typography.titleLarge, - maxLines = 1, - textAlign = TextAlign.Center - ) - } - } -} -@Composable -private fun SelectionActionButtons( - selectedPresets: Set, - timerModel: TimerModel, - usebigassButtons: Boolean, - clearSelection: () -> Unit -) { - val containerModifier = if (usebigassButtons) { - Modifier - .padding(vertical = 16.dp) - .fillMaxWidth(0.76f) - } else { - Modifier - .padding(start = 16.dp, end = 16.dp, top = 24.dp, bottom = 16.dp) - .fillMaxWidth(0.8f) - } - - val buttonHeight = if (usebigassButtons) 96.dp else 56.dp - val textStyle = if (usebigassButtons) MaterialTheme.typography.titleLarge else MaterialTheme.typography.titleMedium - - Row( - modifier = containerModifier, - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - OutlinedButton( - modifier = Modifier - .weight(1f) - .height(buttonHeight), - onClick = { - clearSelection() - } - ) { - Text( - text = stringResource(id = android.R.string.cancel), - style = textStyle, - textAlign = TextAlign.Center - ) - } - FilledTonalButton( - modifier = Modifier - .weight(1f) - .height(buttonHeight), - colors = ButtonDefaults.filledTonalButtonColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHighest, - contentColor = MaterialTheme.colorScheme.error - ), - onClick = { - selectedPresets.sortedDescending().forEach { index -> - timerModel.removePersistentTimer(index) + item(key = "savedTimers") { + Column( + modifier = Modifier + .animateItem(ItemFade, ItemSlide, ItemFade) + .padding(horizontal = 16.dp) + ) { + HorizontalDivider( + modifier = Modifier.padding(top = 12.dp, bottom = 8.dp), + color = MaterialTheme.colorScheme.surfaceVariant + ) + SettingsCategory( + pluralStringResource(R.plurals.saved_timers, timerModel.savedTimers.size) + ) { + if (!isSelectionMode) { + ClickableIcon( + imageVector = Icons.Rounded.AddAlarm, + contentDescription = stringResource(R.string.add_saved_timer) + ) { + timerModel.addSavedTimer(timerModel.timePickerSeconds) + } + } + } } - clearSelection() } - ) { - Text( - text = stringResource(R.string.delete), - style = textStyle, - textAlign = TextAlign.Center - ) - } - } -} + items(timerModel.savedTimers, key = { it.id }) { timer -> + val isSelected = selectedSavedTimerIds.contains(timer.id) -@Composable -@OptIn(ExperimentalFoundationApi::class) -private fun PresetTimers( - timerModel: TimerModel, - onCreateNew: () -> Unit, - context: Context, - selectedPresets: Set, - onSelectedPresetsChanged: (Set) -> Unit -) { - val haptic = LocalHapticFeedback.current - LazyVerticalGrid( - modifier = Modifier - .heightIn(0.dp, 200.dp) - .fillMaxWidth(), - columns = GridCells.Adaptive(100.dp), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - itemsIndexed(items = timerModel.persistentTimers) { index, timer -> - val isSelected = selectedPresets.contains(index) - - Box( - modifier = Modifier - .width(100.dp) - .clip(RoundedCornerShape(16.dp)) - .then( - if (isSelected) { - Modifier.border( - width = 2.dp, - color = MaterialTheme.colorScheme.primary, - shape = RoundedCornerShape(16.dp) - ) - } else Modifier - ) - .combinedClickable( - onClick = { - if (selectedPresets.isNotEmpty()) { - val newSelection = selectedPresets.toMutableSet() - if (isSelected) newSelection.remove(index) else newSelection.add(index) - onSelectedPresetsChanged(newSelection) - } else { - timerModel.timePickerSeconds = timer.seconds - onCreateNew.invoke() - timerModel.startTimer(context) - } - }, - onLongClick = { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - val newSelection = selectedPresets.toMutableSet() + SavedTimerItem( + timer = timer, + isSelected = isSelected, + onStart = { + if (!isSelectionMode) timerModel.startTimer(context, timer) + }, + onClick = { + if (isSelectionMode) { if (isSelected) { - newSelection.remove(index) + selectedSavedTimerIds.remove(timer.id) } else { - newSelection.add(index) + selectedSavedTimerIds.add(timer.id) } - onSelectedPresetsChanged(newSelection) + } else { + editedSavedTimerId = timer.id } - ) - .background(MaterialTheme.colorScheme.secondaryContainer) - .padding(8.dp), - contentAlignment = Alignment.Center - ) { - Text( - timer.formattedTime, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSecondaryContainer + }, + onLongClick = { + if (!isSelectionMode) selectedSavedTimerIds.add(timer.id) + }, + modifier = Modifier.animateItem(ItemFade, ItemSlide, ItemFade) ) } } } -} -@Composable -private fun TimerPickerSelector( - pickerStyle: PickerStyle, timerModel: TimerModel -) { - when (pickerStyle) { - PickerStyle.WHEEL -> Row( - Modifier.fillMaxSize(), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { - ScrollTimerPicker(timerModel) - } + if (activeTimers.isNotEmpty()) { + KeepScreenOn() + } - PickerStyle.NUMBER_PAD -> Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Bottom - ) { - FormattedTimerTime( - seconds = timerModel.timePickerFakeUnits, - modifier = Modifier.padding(bottom = 24.dp) - ) - NumberKeypad( - onOperation = { operation -> - when (operation) { - is NumberKeypadOperation.AddNumber -> timerModel.addNumber(operation.number) - is NumberKeypadOperation.Delete -> timerModel.deleteLastNumber() - is NumberKeypadOperation.Clear -> timerModel.clear() - } - }) - } + editedTimer?.let { timer -> + TimerEditSheet( + currentTimer = timer.settings, + pickerStyle = settingsModel.timerPickerStyle, + onSave = { settings -> + timerModel.updateTimer(timer.id, settings) + editedTimer = null + }, + onDismiss = { editedTimer = null } + ) + } - PickerStyle.CLOCK -> Row( - Modifier.fillMaxSize(), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { - ClockTimePicker( - initialHours = timerModel.hours, - initialMinutes = timerModel.minutes, - is24Hour = true, - onHoursChanged = { timerModel.hours = it }, - onMinutesChanged = { - timerModel.minutes = it - timerModel.seconds = 0 + if (showDeletionDialog) { + AlertDialog( + onDismissRequest = { showDeletionDialog = false }, + title = { Text(text = stringResource(R.string.delete_timers)) }, + text = { Text(text = stringResource(R.string.irreversible)) }, + confirmButton = { + DialogButton(label = R.string.delete, style = DialogButtonStyle.DESTRUCTIVE) { + selectedSavedTimerIds.forEach { timerModel.removeSavedTimer(it) } + selectedSavedTimerIds.clear() + showDeletionDialog = false } - ) - } + }, + dismissButton = { + DialogButton(label = android.R.string.cancel, style = DialogButtonStyle.SECONDARY) { + showDeletionDialog = false + } + } + ) + } + + editedSavedTimerId?.let { id -> + TimerEditSheet( + currentTimer = timerModel.savedTimers.first { it.id == id }, + pickerStyle = settingsModel.timerPickerStyle, + onSave = { settings -> + timerModel.updateSavedTimer(settings) + editedSavedTimerId = null + }, + onDelete = { + timerModel.removeSavedTimer(id) + editedSavedTimerId = null + }, + onDismiss = { editedSavedTimerId = null } + ) } } diff --git a/app/src/main/java/com/bnyro/clock/presentation/screens/timer/components/FormattedTimerTime.kt b/app/src/main/java/com/bnyro/clock/presentation/screens/timer/components/FormattedTimerTime.kt index 2f5de8180..a003947a0 100644 --- a/app/src/main/java/com/bnyro/clock/presentation/screens/timer/components/FormattedTimerTime.kt +++ b/app/src/main/java/com/bnyro/clock/presentation/screens/timer/components/FormattedTimerTime.kt @@ -13,12 +13,10 @@ import com.bnyro.clock.domain.model.TimeUnit @Composable fun FormattedTimerTime( modifier: Modifier = Modifier, + hours: Int, + minutes: Int, seconds: Int ) { - val remainingSeconds = seconds % 100 - val minutes = seconds / 100 % 100 - val hours = seconds / 10000 % 100 - Row( horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically, @@ -36,7 +34,7 @@ fun FormattedTimerTime( ) FormattedUnitTime( unit = TimeUnit.Seconds, - value = remainingSeconds, + value = seconds, isActive = seconds > 0 || minutes > 0 || hours > 0 ) } diff --git a/app/src/main/java/com/bnyro/clock/presentation/screens/timer/components/NumberKeypad.kt b/app/src/main/java/com/bnyro/clock/presentation/screens/timer/components/NumberKeypad.kt index 8bd67551a..9b6c6091f 100644 --- a/app/src/main/java/com/bnyro/clock/presentation/screens/timer/components/NumberKeypad.kt +++ b/app/src/main/java/com/bnyro/clock/presentation/screens/timer/components/NumberKeypad.kt @@ -2,23 +2,24 @@ package com.bnyro.clock.presentation.screens.timer.components import android.view.HapticFeedbackConstants import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Backspace -import androidx.compose.material.icons.filled.Backspace import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.bnyro.clock.R import com.bnyro.clock.domain.model.NumberKeypadOperation @@ -26,48 +27,55 @@ import kotlinx.coroutines.launch @Composable fun NumberKeypad( - onOperation: (NumberKeypadOperation) -> Unit + onOperation: (NumberKeypadOperation) -> Unit, + modifier: Modifier = Modifier ) { val view = LocalView.current val coroutineScope = rememberCoroutineScope() - val screenHeight = LocalConfiguration.current.screenHeightDp - - val buttonSize = (screenHeight / 8.5).dp - val buttonSpacing = 6.dp + val buttonSpacing = 12.dp Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(buttonSpacing) ) { - Row( - horizontalArrangement = Arrangement.spacedBy(buttonSpacing), - modifier = Modifier.weight(1f) - ) { - NumPadButton(number = "1", buttonSize, onOperation) - NumPadButton(number = "2", buttonSize, onOperation) - NumPadButton(number = "3", buttonSize, onOperation) - } - Row( - horizontalArrangement = Arrangement.spacedBy(buttonSpacing), - modifier = Modifier.weight(1f) - ) { - NumPadButton(number = "4", buttonSize, onOperation) - NumPadButton(number = "5", buttonSize, onOperation) - NumPadButton(number = "6", buttonSize, onOperation) - } - Row( - horizontalArrangement = Arrangement.spacedBy(buttonSpacing), - modifier = Modifier.weight(1f) - ) { - NumPadButton(number = "7", buttonSize, onOperation) - NumPadButton(number = "8", buttonSize, onOperation) - NumPadButton(number = "9", buttonSize, onOperation) + val rows = listOf( + listOf("1", "2", "3"), + listOf("4", "5", "6"), + listOf("7", "8", "9") + ) + + rows.forEach { rowNumbers -> + Row( + horizontalArrangement = Arrangement.spacedBy(buttonSpacing), + modifier = Modifier.fillMaxWidth() + ) { + rowNumbers.forEach { number -> + NumPadButton( + number = number, + modifier = Modifier.weight(1f), + onOperation = onOperation + ) + } + } } + + // Bottom row Row( horizontalArrangement = Arrangement.spacedBy(buttonSpacing), - modifier = Modifier.weight(1f) + modifier = Modifier.fillMaxWidth() ) { - NumPadButton(number = "00", buttonSize, onOperation) - NumPadButton(number = "0", buttonSize, onOperation) + NumPadButton( + number = "00", + modifier = Modifier.weight(1f), + onOperation = onOperation + ) + NumPadButton( + number = "0", + modifier = Modifier.weight(1f), + onOperation = onOperation + ) SingleElementButton( onClick = { @@ -83,13 +91,20 @@ fun NumberKeypad( onOperation(NumberKeypadOperation.Clear) }, color = MaterialTheme.colorScheme.secondaryContainer, - modifier = Modifier.size(buttonSize) + modifier = Modifier + .weight(1f) + .aspectRatio(1f) ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.Backspace, - contentDescription = stringResource(R.string.delete), - tint = MaterialTheme.colorScheme.onSecondaryContainer - ) + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Backspace, + contentDescription = stringResource(R.string.delete), + tint = MaterialTheme.colorScheme.onSecondaryContainer + ) + } } } } @@ -98,7 +113,7 @@ fun NumberKeypad( @Composable fun NumPadButton( number: String, - buttonSize: Dp, + modifier: Modifier = Modifier, onOperation: (NumberKeypadOperation) -> Unit ) { val view = LocalView.current @@ -111,13 +126,18 @@ fun NumPadButton( } onOperation(NumberKeypadOperation.AddNumber(number)) }, - modifier = Modifier.size(buttonSize), + modifier = modifier.aspectRatio(1f), color = MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp) ) { - Text( - text = number, - color = MaterialTheme.colorScheme.onSurface, - fontSize = MaterialTheme.typography.displaySmall.fontSize - ) + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + Text( + text = number, + color = MaterialTheme.colorScheme.onSurface, + fontSize = MaterialTheme.typography.displaySmall.fontSize + ) + } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bnyro/clock/presentation/screens/timer/components/SavedTimerItem.kt b/app/src/main/java/com/bnyro/clock/presentation/screens/timer/components/SavedTimerItem.kt new file mode 100644 index 000000000..7efbe85ee --- /dev/null +++ b/app/src/main/java/com/bnyro/clock/presentation/screens/timer/components/SavedTimerItem.kt @@ -0,0 +1,111 @@ +package com.bnyro.clock.presentation.screens.timer.components + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.border +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Label +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.bnyro.clock.domain.model.TimerSettings +import com.bnyro.clock.util.extensions.addZero + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun SavedTimerItem( + timer: TimerSettings, + isSelected: Boolean, + onStart: () -> Unit, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier +) { + val hours = timer.seconds / 3600 + val minutes = timer.seconds % 3600 / 60 + val seconds = timer.seconds % 60 + val cardShape = RoundedCornerShape(20.dp) + + ElevatedCard( + modifier = modifier + .padding(horizontal = 12.dp, vertical = 6.dp) + .clip(cardShape) + .then( + if (isSelected) { + Modifier.border( + width = 2.dp, + color = MaterialTheme.colorScheme.primary, + shape = cardShape + ) + } else { + Modifier + } + ) + .combinedClickable(onClick = onClick, onLongClick = onLongClick), + shape = cardShape, + colors = CardDefaults.elevatedCardColors() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Label, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(5.dp)) + Text( + text = timer.label, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Normal, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + Text( + text = "$hours:${minutes.addZero()}:${seconds.addZero()}", + style = MaterialTheme.typography.displaySmall, + fontWeight = FontWeight.Normal, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Visible + ) + } + + FilledIconButton( + modifier = Modifier + .padding(start = 4.dp) + .size(48.dp), + onClick = onStart + ) { + Icon(imageVector = Icons.Default.PlayArrow, contentDescription = null) + } + } + } +} diff --git a/app/src/main/java/com/bnyro/clock/presentation/screens/timer/components/TimerEditSheet.kt b/app/src/main/java/com/bnyro/clock/presentation/screens/timer/components/TimerEditSheet.kt new file mode 100644 index 000000000..6cea33adb --- /dev/null +++ b/app/src/main/java/com/bnyro/clock/presentation/screens/timer/components/TimerEditSheet.kt @@ -0,0 +1,268 @@ +package com.bnyro.clock.presentation.screens.timer.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.Label +import androidx.compose.material.icons.rounded.Alarm +import androidx.compose.material.icons.rounded.MoreTime +import androidx.compose.material.icons.rounded.Vibration +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.bnyro.clock.R +import com.bnyro.clock.domain.model.PickerStyle +import com.bnyro.clock.domain.model.TimerSettings +import com.bnyro.clock.presentation.components.ScrollPickerDialog +import com.bnyro.clock.presentation.components.SwitchWithDivider +import com.bnyro.clock.presentation.features.RingtonePickerDialog +import com.bnyro.clock.presentation.features.VibrationPatternPickerDialog +import com.bnyro.clock.util.Preferences +import com.bnyro.clock.util.TimeHelper + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TimerEditSheet( + currentTimer: TimerSettings, + pickerStyle: PickerStyle, + onSave: (TimerSettings) -> Unit, + onDelete: (() -> Unit)? = null, + onDismiss: () -> Unit +) { + var showRingtoneDialog by remember { mutableStateOf(false) } + var showVibrationDialog by remember { mutableStateOf(false) } + var showIncrementDialog by remember { mutableStateOf(false) } + + var seconds by remember { mutableIntStateOf(currentTimer.seconds) } + var label by remember { mutableStateOf(currentTimer.label) } + var soundName by remember { mutableStateOf(currentTimer.soundName) } + var soundUri by remember { mutableStateOf(currentTimer.soundUri) } + var soundEnabled by remember { mutableStateOf(currentTimer.soundEnabled) } + var vibrationEnabled by remember { mutableStateOf(currentTimer.vibrate) } + var vibrationPattern by remember { mutableStateOf(currentTimer.vibrationPattern) } + var vibrationPatternName by remember { mutableStateOf(currentTimer.vibrationPatternName) } + var incrementSeconds by remember { mutableStateOf(currentTimer.incrementSeconds) } + + val scrollState = rememberScrollState() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + .padding(bottom = 16.dp) + ) { + Column( + Modifier + .weight(1f, fill = false) + .fillMaxWidth() + .verticalScroll(scrollState), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceEvenly + ) { + TimerPickerSelector( + pickerStyle = pickerStyle, + seconds = seconds, + onSecondsChanged = { seconds = it } + ) + Spacer(modifier = Modifier.height(16.dp)) + + Column { + Row( + modifier = Modifier.padding(8.dp, 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = label, + onValueChange = { + label = it + }, + label = { + Text(text = stringResource(id = R.string.label)) + }, + singleLine = false, + maxLines = 3, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Default + ), + leadingIcon = { + Icon( + imageVector = Icons.AutoMirrored.Outlined.Label, + contentDescription = null + ) + } + ) + } + SwitchWithDivider( + title = stringResource(R.string.sound), + description = soundName ?: stringResource(R.string.default_sound), + isChecked = soundEnabled, + icon = Icons.Rounded.Alarm, + onClick = { + showRingtoneDialog = true + }, + onChecked = { + soundEnabled = it + } + ) + SwitchWithDivider( + title = stringResource(R.string.vibrate), + description = stringResource( + id = R.string.vibration_pattern, + vibrationPatternName + ), + isChecked = vibrationEnabled, + icon = Icons.Rounded.Vibration, + onClick = { + showVibrationDialog = true + }, + onChecked = { newValue -> + vibrationEnabled = newValue + } + ) + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { showIncrementDialog = true } + .padding(8.dp, 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Rounded.MoreTime, + contentDescription = null, + modifier = Modifier + .padding(start = 8.dp, end = 16.dp) + .size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Column(Modifier.weight(1f)) { + Text( + text = stringResource(R.string.timer_increment), + style = MaterialTheme.typography.titleLarge + ) + Text( + text = incrementSeconds?.let { + pluralStringResource(R.plurals.seconds_count, it, it) + } ?: stringResource(R.string.default_increment), + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + onDelete?.let { + FilledTonalButton( + onClick = it, + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + contentColor = MaterialTheme.colorScheme.error + ) + ) { + Text(text = stringResource(R.string.delete)) + } + } + Spacer(modifier = Modifier.weight(1f)) + OutlinedButton(onClick = onDismiss) { + Text(text = stringResource(id = android.R.string.cancel)) + } + Spacer(modifier = Modifier.width(16.dp)) + Button( + enabled = seconds > 0, + onClick = { + onSave( + currentTimer.copy( + seconds = seconds, + // a timer without a name of its own is named by its duration + label = label.ifBlank { TimeHelper.durationToName(seconds) }, + soundName = soundName, + soundUri = soundUri, + soundEnabled = soundEnabled, + vibrate = vibrationEnabled, + vibrationPattern = vibrationPattern, + vibrationPatternName = vibrationPatternName, + incrementSeconds = incrementSeconds + ) + ) + } + ) { + Text(text = stringResource(R.string.save)) + } + } + } + } + if (showRingtoneDialog) { + RingtonePickerDialog(onDismissRequest = { + showRingtoneDialog = false + }) { title, uri -> + soundUri = uri?.toString() + soundName = title + } + } + if (showVibrationDialog) { + VibrationPatternPickerDialog( + onDismissRequest = { showVibrationDialog = false }, + onSelectPattern = { + vibrationPattern = it.pattern + vibrationPatternName = it.name + showVibrationDialog = false + }, + selectedPattern = vibrationPatternName + ) + } + if (showIncrementDialog) { + ScrollPickerDialog( + onDismissRequest = { showIncrementDialog = false }, + title = stringResource(R.string.select_timer_increment), + unit = stringResource(R.string.seconds), + value = incrementSeconds + ?: Preferences.instance.getInt(Preferences.timerIncrementSecondsKey, 60), + maxValue = 61, + offset = 0, + label = { it.toString() }, + onValueSet = { + incrementSeconds = it.takeIf { seconds -> seconds > 0 } + showIncrementDialog = false + } + ) + } +} diff --git a/app/src/main/java/com/bnyro/clock/presentation/screens/timer/components/TimerItem.kt b/app/src/main/java/com/bnyro/clock/presentation/screens/timer/components/TimerItem.kt index b3863e2d4..63091e65c 100644 --- a/app/src/main/java/com/bnyro/clock/presentation/screens/timer/components/TimerItem.kt +++ b/app/src/main/java/com/bnyro/clock/presentation/screens/timer/components/TimerItem.kt @@ -14,62 +14,57 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Label import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.MoreTime import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material3.AlertDialog import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Checkbox import androidx.compose.material3.ElevatedCard import androidx.compose.material3.FilledIconButton import androidx.compose.material3.Icon import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import com.bnyro.clock.R import com.bnyro.clock.domain.model.TimerObject import com.bnyro.clock.domain.model.WatchState import com.bnyro.clock.presentation.components.ClickableIcon -import com.bnyro.clock.presentation.components.DialogButton -import com.bnyro.clock.presentation.components.DialogButtonStyle -import com.bnyro.clock.presentation.features.RingtonePickerDialog import com.bnyro.clock.presentation.screens.timer.model.TimerModel import com.bnyro.clock.util.TimeHelper import com.bnyro.clock.util.extensions.addZero import java.time.ZonedDateTime @Composable -fun TimerItem(obj: TimerObject, timerModel: TimerModel) { +fun TimerItem( + obj: TimerObject, + timerModel: TimerModel, + onEdit: () -> Unit, + modifier: Modifier = Modifier +) { val context = LocalContext.current val isFinished = obj.currentPosition.value <= 0 - val hours = obj.currentPosition.value / 3600000 - val minutes = (obj.currentPosition.value % 3600000) / 60000 - val seconds = (obj.currentPosition.value % 60000) / 1000 - - var showLabelEditor by remember { mutableStateOf(false) } - var showRingtoneEditor by remember { mutableStateOf(false) } + val hours = obj.secondsLeft / 3600 + val minutes = (obj.secondsLeft % 3600) / 60 + val seconds = obj.secondsLeft % 60 + val cardShape = RoundedCornerShape(20.dp) ElevatedCard( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), - shape = RoundedCornerShape(20.dp), + modifier = modifier + .padding(horizontal = 12.dp, vertical = 6.dp) + .clip(cardShape) + .clickable(onClick = onEdit), + shape = cardShape, colors = CardDefaults.elevatedCardColors() ) { Column { @@ -82,10 +77,15 @@ fun TimerItem(obj: TimerObject, timerModel: TimerModel) { val mutedContentColor = MaterialTheme.colorScheme.onSurfaceVariant Column(modifier = Modifier.weight(1f)) { - val titleText = obj.label.value ?: if (isFinished) stringResource(R.string.timer_finished) else null - titleText?.let { label -> + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Label, + contentDescription = null, + tint = mutedContentColor + ) + Spacer(modifier = Modifier.width(5.dp)) Text( - text = label, + text = obj.label.value, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Normal, color = mutedContentColor, @@ -108,8 +108,6 @@ fun TimerItem(obj: TimerObject, timerModel: TimerModel) { Row( modifier = Modifier .offset(x = (-6).dp, y = (2.dp)) - .clip(RoundedCornerShape(8.dp)) - .clickable { showRingtoneEditor = true } .padding(horizontal = 6.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically ) { @@ -168,8 +166,8 @@ fun TimerItem(obj: TimerObject, timerModel: TimerModel) { horizontalArrangement = Arrangement.spacedBy((-10).dp), verticalAlignment = Alignment.CenterVertically ) { - ClickableIcon(imageVector = Icons.Default.Edit) { - showLabelEditor = true + ClickableIcon(imageVector = Icons.Default.MoreTime) { + timerModel.addTimeToTimer(context, obj.id) } ClickableIcon(imageVector = Icons.Default.Refresh) { @@ -201,56 +199,10 @@ fun TimerItem(obj: TimerObject, timerModel: TimerModel) { .fillMaxWidth() .padding(start = 16.dp, end = 16.dp, bottom = 16.dp) .height(8.dp), - progress = { obj.currentPosition.value / obj.initialPosition.toFloat() }, + progress = { obj.currentPosition.value / obj.initialPosition.value.toFloat() }, strokeCap = StrokeCap.Round ) } } } - - if (showLabelEditor) { - var newLabel by remember { mutableStateOf(obj.label.value.orEmpty()) } - AlertDialog( - onDismissRequest = { showLabelEditor = false }, - confirmButton = { - DialogButton(R.string.save, DialogButtonStyle.PRIMARY) { - timerModel.updateLabel(obj.id, newLabel) - showLabelEditor = false - } - }, - dismissButton = { - DialogButton(android.R.string.cancel, DialogButtonStyle.SECONDARY) { - showLabelEditor = false - } - }, - title = { Text(stringResource(R.string.label)) }, - text = { - OutlinedTextField( - value = newLabel, - onValueChange = { newLabel = it }, - label = { Text(stringResource(R.string.label)) } - ) - } - ) - } - - if (showRingtoneEditor) { - RingtonePickerDialog( - onDismissRequest = { showRingtoneEditor = false }, - bottomContent = { - Row( - modifier = Modifier.align(Alignment.Start), - verticalAlignment = Alignment.CenterVertically - ) { - Checkbox( - checked = obj.vibrate, - onCheckedChange = { timerModel.updateVibrate(obj.id, it) } - ) - Text(text = stringResource(R.string.vibrate)) - } - } - ) { _, uri -> - timerModel.updateRingtone(obj.id, uri) - } - } } diff --git a/app/src/main/java/com/bnyro/clock/presentation/screens/timer/components/TimerPickerSelector.kt b/app/src/main/java/com/bnyro/clock/presentation/screens/timer/components/TimerPickerSelector.kt new file mode 100644 index 000000000..ffcd9e811 --- /dev/null +++ b/app/src/main/java/com/bnyro/clock/presentation/screens/timer/components/TimerPickerSelector.kt @@ -0,0 +1,127 @@ +package com.bnyro.clock.presentation.screens.timer.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.bnyro.clock.domain.model.NumberKeypadOperation +import com.bnyro.clock.domain.model.PickerStyle +import com.bnyro.clock.presentation.components.ClockTimePicker +import com.bnyro.clock.presentation.components.ScrollTimerPicker + +@Composable +fun TimerPickerSelector( + pickerStyle: PickerStyle, + seconds: Int, + onSecondsChanged: (Int) -> Unit, + modifier: Modifier = Modifier +) { + Box(modifier = modifier) { + TimerPicker(pickerStyle, seconds, onSecondsChanged) + } +} + +@Composable +private fun TimerPicker( + pickerStyle: PickerStyle, + seconds: Int, + onSecondsChanged: (Int) -> Unit +) { + when (pickerStyle) { + PickerStyle.WHEEL -> ScrollTimerPicker( + seconds = seconds, + onSecondsChanged = onSecondsChanged + ) + + PickerStyle.NUMBER_PAD -> NumberPadTimerPicker( + seconds = seconds, + onSecondsChanged = onSecondsChanged + ) + + PickerStyle.CLOCK -> { + var chosenHours by remember { mutableIntStateOf(seconds / 3600) } + var chosenMinutes by remember { mutableIntStateOf(seconds % 3600 / 60) } + + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + ClockTimePicker( + initialHours = chosenHours, + initialMinutes = chosenMinutes, + is24Hour = true, + onHoursChanged = { + chosenHours = it + onSecondsChanged(chosenHours * 3600 + chosenMinutes * 60) + }, + onMinutesChanged = { + chosenMinutes = it + onSecondsChanged(chosenHours * 3600 + chosenMinutes * 60) + } + ) + } + } + } +} + +@Composable +private fun NumberPadTimerPicker( + seconds: Int, + onSecondsChanged: (Int) -> Unit +) { + // the digits are typed right to left, so they are held as they read: HHMMSS + var digits by remember { + mutableIntStateOf(seconds / 3600 * 10000 + seconds % 3600 / 60 * 100 + seconds % 60) + } + val chosenHours = digits / 10000 % 100 + val chosenMinutes = digits / 100 % 100 + val chosenSeconds = digits % 100 + + val pushDigits = { newDigits: Int -> + digits = newDigits + onSecondsChanged( + newDigits / 10000 % 100 * 3600 + newDigits / 100 % 100 * 60 + newDigits % 100 + ) + } + + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + FormattedTimerTime( + modifier = Modifier.padding(vertical = 24.dp), + hours = chosenHours, + minutes = chosenMinutes, + seconds = chosenSeconds + ) + NumberKeypad( + onOperation = { operation -> + when (operation) { + // don't do anything if all necessary/possible numbers have been entered already + is NumberKeypadOperation.AddNumber -> { + if (chosenHours * 3600 + chosenMinutes * 60 + chosenSeconds < 10 * 3600) { + pushDigits( + if (operation.number == "00") { + digits * 100 + } else { + digits * 10 + operation.number.toInt() + } + ) + } + } + + is NumberKeypadOperation.Delete -> pushDigits(digits / 10) + is NumberKeypadOperation.Clear -> pushDigits(0) + } + } + ) + } +} diff --git a/app/src/main/java/com/bnyro/clock/presentation/screens/timer/model/TimerModel.kt b/app/src/main/java/com/bnyro/clock/presentation/screens/timer/model/TimerModel.kt index 609a07e0c..5a27fd5ea 100644 --- a/app/src/main/java/com/bnyro/clock/presentation/screens/timer/model/TimerModel.kt +++ b/app/src/main/java/com/bnyro/clock/presentation/screens/timer/model/TimerModel.kt @@ -2,15 +2,14 @@ package com.bnyro.clock.presentation.screens.timer.model import android.content.Context import android.content.Intent -import android.net.Uri import androidx.compose.runtime.SnapshotMutationPolicy import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel -import com.bnyro.clock.domain.model.PersistentTimer import com.bnyro.clock.domain.model.TimerDescriptor import com.bnyro.clock.domain.model.TimerObject +import com.bnyro.clock.domain.model.TimerSettings import com.bnyro.clock.util.services.TimerService import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -20,67 +19,57 @@ class TimerModel : ViewModel() { val scheduledObjects = _timerObjects.asStateFlow() var onEnqueue: ((timer: TimerObject) -> Unit)? = null - var updateLabel: (id: Int, newLabel: String) -> Unit = { _, _ -> } - var updateRingtone: (id: Int, newRingtoneUri: Uri?) -> Unit = { _, _ -> } - var updateVibrate: (id: Int, vibrate: Boolean) -> Unit = { _, _ -> } - - var persistentTimers by mutableStateOf( - PersistentTimer.getTimers(), - policy = object : SnapshotMutationPolicy> { - override fun equivalent(a: List, b: List): Boolean { + var updateTimer: (id: Int, settings: TimerSettings) -> Unit = { _, _ -> } + + var savedTimers by mutableStateOf( + TimerSettings.getSavedTimers(), + policy = object : SnapshotMutationPolicy> { + override fun equivalent(a: List, b: List): Boolean { if (a == b) return true - PersistentTimer.setTimers(b) + TimerSettings.setSavedTimers(b) return false } } ) - - - var timePickerSeconds = 0 - var hours - get() = timePickerSeconds / 3600 - set(value) { - timePickerSeconds += (value - hours) * 3600 - } - var minutes - get() = (timePickerSeconds % 3600) / 60 - set(value) { - timePickerSeconds += (value - minutes) * 60 - } - var seconds - get() = (timePickerSeconds % 3600) % 60 - set(value) { - timePickerSeconds += (value - seconds) - } - + var timePickerSeconds by mutableStateOf(60) fun onChangeTimers(objects: Array) { _timerObjects.value = listOf(*objects) } - fun removePersistentTimer(index: Int) { - persistentTimers = persistentTimers.filterIndexed { i, _ -> i != index } + fun removeSavedTimer(id: Int) { + savedTimers = savedTimers.filter { it.id != id } } - fun addPersistentTimer(seconds: Int) { + fun addSavedTimer(seconds: Int) { if (seconds == 0) return - persistentTimers = (persistentTimers + PersistentTimer(seconds)).distinct() + val newTimer = TimerSettings(seconds = seconds) + if (savedTimers.any { it.copy(id = 0) == newTimer }) return + savedTimers = savedTimers + newTimer.copy( + id = (savedTimers.maxOfOrNull { it.id } ?: 0) + 1 + ) + } + + fun copySavedTimer(timer: TimerSettings) { + savedTimers = savedTimers + timer.copy( + id = (savedTimers.maxOfOrNull { it.id } ?: 0) + 1 + ) + } + + fun updateSavedTimer(settings: TimerSettings) { + savedTimers = savedTimers.map { if (it.id == settings.id) settings else it } } - fun startTimer(context: Context, delay: Int? = null) { - val totalSeconds = delay ?: timePickerSeconds - if (totalSeconds == 0) return + fun startTimer(context: Context, settings: TimerSettings) { + if (settings.seconds == 0) return val newTimer = TimerDescriptor( // id randomized by system current time; used modulo to compensate for integer overflow id = (System.currentTimeMillis() % Int.MAX_VALUE).toInt(), - currentPosition = totalSeconds * 1000 + settings = settings ) - timePickerSeconds = 0 - timePickerFakeUnits = 0 - if (_timerObjects.value.isEmpty()) { startService(context, newTimer) } else { @@ -94,80 +83,19 @@ class TimerModel : ViewModel() { context.startService(intent) } - fun pauseResumeTimer(context: Context, index: Int) { - val pauseResumeIntent = Intent(TimerService.UPDATE_STATE_ACTION) - .putExtra( - TimerService.ID_EXTRA_KEY, - index - ) - .putExtra( - TimerService.ACTION_EXTRA_KEY, - TimerService.ACTION_PAUSE_RESUME - ) - context.sendBroadcast(pauseResumeIntent) - } - - fun stopTimer(context: Context, index: Int) { - val stopIntent = Intent(TimerService.UPDATE_STATE_ACTION) - .putExtra( - TimerService.ID_EXTRA_KEY, - index - ) - .putExtra( - TimerService.ACTION_EXTRA_KEY, - TimerService.ACTION_STOP - ) - context.sendBroadcast(stopIntent) + fun pauseResumeTimer(context: Context, id: Int) { + context.sendBroadcast(TimerService.updateStateIntent(TimerService.ACTION_PAUSE_RESUME, id)) } - fun restartTimer(context: Context, index: Int) { - val restartIntent = Intent(TimerService.UPDATE_STATE_ACTION) - .putExtra( - TimerService.ID_EXTRA_KEY, - index - ) - .putExtra( - TimerService.ACTION_EXTRA_KEY, - TimerService.TIMER_RESTART - ) - context.sendBroadcast(restartIntent) - } - - /* =============== Numpad time picker ======================== */ - var timePickerFakeUnits by mutableStateOf( - 0, - policy = object : SnapshotMutationPolicy { - override fun equivalent(a: Int, b: Int): Boolean { - if (a == b) return true - b.let { - val roughHours = (it / 10000) % 100 - val roughMinutes = (it / 100) % 100 - val roughSeconds = it % 100 - timePickerSeconds = - roughSeconds + (roughMinutes * 60) + (roughHours * 3600) - } - return false - } - } - ) - - fun addNumber(number: String) { - // don't do anything if all necessary/possible numbers have been entered already - if (hours >= 10) return - - if (number == "00") { - timePickerFakeUnits *= 100 - return - } - timePickerFakeUnits = (timePickerFakeUnits * 10) + number.toInt() + fun stopTimer(context: Context, id: Int) { + context.sendBroadcast(TimerService.updateStateIntent(TimerService.ACTION_STOP, id)) } - fun deleteLastNumber() { - timePickerFakeUnits /= 10 + fun addTimeToTimer(context: Context, id: Int) { + context.sendBroadcast(TimerService.updateStateIntent(TimerService.ACTION_ADD_TIME, id)) } - fun clear() { - timePickerFakeUnits = 0 + fun restartTimer(context: Context, id: Int) { + context.sendBroadcast(TimerService.updateStateIntent(TimerService.TIMER_RESTART, id)) } - /* ========================================================== */ } diff --git a/app/src/main/java/com/bnyro/clock/ui/MainActivity.kt b/app/src/main/java/com/bnyro/clock/ui/MainActivity.kt index e0bb8c63d..d87389058 100644 --- a/app/src/main/java/com/bnyro/clock/ui/MainActivity.kt +++ b/app/src/main/java/com/bnyro/clock/ui/MainActivity.kt @@ -74,9 +74,7 @@ class MainActivity : ComponentActivity() { timerModel.onEnqueue = { timerService.enqueueNew(it) } - timerModel.updateLabel = timerService::updateLabel - timerModel.updateRingtone = timerService::updateRingtone - timerModel.updateVibrate = timerService::updateVibrate + timerModel.updateTimer = timerService::updateTimer timerService.invokeChangeListener() } @@ -84,9 +82,7 @@ class MainActivity : ComponentActivity() { override fun onServiceDisconnected(p0: ComponentName?) { timerService.onChangeTimers = {} timerModel.onEnqueue = null - timerModel.updateLabel = { _, _ -> } - timerModel.updateRingtone = { _, _ -> } - timerModel.updateVibrate = { _, _ -> } + timerModel.updateTimer = { _, _ -> } } } diff --git a/app/src/main/java/com/bnyro/clock/ui/theme/Motion.kt b/app/src/main/java/com/bnyro/clock/ui/theme/Motion.kt new file mode 100644 index 000000000..a546af547 --- /dev/null +++ b/app/src/main/java/com/bnyro/clock/ui/theme/Motion.kt @@ -0,0 +1,26 @@ +package com.bnyro.clock.ui.theme + +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.tween +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize + +/** + * The weight a card takes on as it arrives in a list, and gives up as it leaves. + */ +const val ItemFadeDurationMillis = 120 +val ItemFade: FiniteAnimationSpec = + tween(durationMillis = ItemFadeDurationMillis, easing = FastOutSlowInEasing) + +/** + * The travel of a card the list moves aside to make room for another, or to close the gap one left. + */ +val ItemSlide: FiniteAnimationSpec = + tween(durationMillis = 220, easing = FastOutSlowInEasing) + +/** + * The growth of a list that has gained a row, or the shrink of one that has lost it. + */ +val ListResize: FiniteAnimationSpec = + tween(durationMillis = 220, easing = FastOutSlowInEasing) diff --git a/app/src/main/java/com/bnyro/clock/util/NotificationHelper.kt b/app/src/main/java/com/bnyro/clock/util/NotificationHelper.kt index 26a84b759..e27043d3f 100644 --- a/app/src/main/java/com/bnyro/clock/util/NotificationHelper.kt +++ b/app/src/main/java/com/bnyro/clock/util/NotificationHelper.kt @@ -9,15 +9,16 @@ import androidx.core.app.NotificationChannelCompat import androidx.core.app.NotificationManagerCompat import com.bnyro.clock.R import com.bnyro.clock.util.receivers.DeleteNotificationChannelReceiver +import com.bnyro.clock.util.receivers.PreAlarmReceiver object NotificationHelper { const val STOPWATCH_CHANNEL = "stopwatch" - const val TIMER_CHANNEL = "timer" - const val TIMER_FINISHED_CHANNEL = "timer_finished" - const val ALARM_CHANNEL = "alarm" + const val TIMER_CHANNEL = "timer_ongoing" + const val TIMER_FINISHED_CHANNEL = "timer_finished_silent" + const val ALARM_CHANNEL = "alarm_silent" const val MISSED_ALARM_CHANNEL = "missed_alarm" - val vibrationPattern = longArrayOf(1000, 1000, 1000, 1000, 1000) + val vibrationPattern = longArrayOf(0, 1000, 1000, 1000, 1000) val audioAttributes: AudioAttributes? = AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_ALARM) @@ -69,21 +70,24 @@ object NotificationHelper { .build(), NotificationChannelCompat.Builder( TIMER_CHANNEL, - NotificationManagerCompat.IMPORTANCE_LOW + NotificationManagerCompat.IMPORTANCE_DEFAULT ) .setName(context.getString(R.string.timer)) + .setSound(null, null) .build(), NotificationChannelCompat.Builder( TIMER_FINISHED_CHANNEL, NotificationManagerCompat.IMPORTANCE_MAX ) .setName(context.getString(R.string.timer_finished)) + .setSound(null, null) .build(), NotificationChannelCompat.Builder( ALARM_CHANNEL, NotificationManagerCompat.IMPORTANCE_MAX ) .setName(context.getString(R.string.alarm)) + .setSound(null, null) .build(), NotificationChannelCompat.Builder( MISSED_ALARM_CHANNEL, @@ -94,5 +98,6 @@ object NotificationHelper { ) nManager.createNotificationChannelsCompat(channels) + nManager.deleteUnlistedNotificationChannels(channels.map { it.id } + PreAlarmReceiver.CHANNEL_ID) } } diff --git a/app/src/main/java/com/bnyro/clock/util/Preferences.kt b/app/src/main/java/com/bnyro/clock/util/Preferences.kt index 3a58f8bfe..347dfe245 100644 --- a/app/src/main/java/com/bnyro/clock/util/Preferences.kt +++ b/app/src/main/java/com/bnyro/clock/util/Preferences.kt @@ -4,7 +4,9 @@ import android.content.Context import android.content.SharedPreferences import androidx.core.content.edit import com.bnyro.clock.domain.model.PickerStyle +import com.bnyro.clock.domain.model.TimerSettings import com.bnyro.clock.navigation.homeRoutes +import kotlinx.serialization.json.Json object Preferences { lateinit var instance: SharedPreferences @@ -13,9 +15,16 @@ object Preferences { const val themeKey = "theme" const val timerPickerStyleKey = "timerUsePicker" const val alarmPickerStyleKey = "alarm_use_scroll_picker" - const val timerShowExamplesKey = "timerShowExamples" const val clockSortOrder = "clockSortOrder" - const val persistentTimerKey = "persistentTimers" + const val savedTimersKey = "savedTimers" + const val timerBigStartButtonKey = "timerBigStartButton" + const val timerIncrementSecondsKey = "timerIncrementSeconds" + const val timerPickerBehaviourKey = "timerPickerBehaviour" + const val timerFullScreenAlertKey = "timerFullScreenAlert" + const val timerTimeoutMinutesKey = "timerTimeoutMinutes" + const val timerVolumeRampSecondsKey = "timerVolumeRampSeconds" + const val alarmVolumeRampSecondsKey = "alarmVolumeRampSeconds" + const val timerVolumeButtonActionKey = "timerVolumeButtonAction" const val snoozeTimeMinutesKey = "snoozeTimeMinutes" const val alarmTimeoutMinutesKey = "alarmTimeoutMinutes" const val customColorKey = "customColor" @@ -30,6 +39,10 @@ object Preferences { val timerPickerStyle = instance.all[timerPickerStyleKey] val alarmPickerStyle = instance.all[alarmPickerStyleKey] + val savedTimers = instance.all[savedTimersKey] + val persistentTimers = instance.all["persistentTimers"] + val migratedBigStartButton = instance.all[timerBigStartButtonKey] + val oldBigStartButton = instance.all["timer_BIG_start_button"] instance.edit { if (timerPickerStyle is Boolean) { putString( @@ -43,6 +56,20 @@ object Preferences { if (alarmPickerStyle) PickerStyle.NUMBER_PAD.name else PickerStyle.WHEEL.name ) } + if (savedTimers == null && persistentTimers is String) { + putString( + savedTimersKey, + Json.encodeToString( + persistentTimers.split(",").mapNotNull { it.toIntOrNull() } + .map { TimerSettings(seconds = it) } + ) + ) + remove("persistentTimers") + } + if (migratedBigStartButton == null && oldBigStartButton is Boolean) { + putBoolean(timerBigStartButtonKey, oldBigStartButton) + remove("timer_BIG_start_button") + } } } diff --git a/app/src/main/java/com/bnyro/clock/util/TimeHelper.kt b/app/src/main/java/com/bnyro/clock/util/TimeHelper.kt index 921fead8b..e609ecaac 100644 --- a/app/src/main/java/com/bnyro/clock/util/TimeHelper.kt +++ b/app/src/main/java/com/bnyro/clock/util/TimeHelper.kt @@ -59,8 +59,8 @@ object TimeHelper { ) } - fun formatTime(context: Context, time: ZonedDateTime): String = - formatSystemTime(context, time.toInstant(), TimeZone.getTimeZone(time.zone), false) + fun formatTime(context: Context, time: ZonedDateTime, showSeconds: Boolean = false): String = + formatSystemTime(context, time.toInstant(), TimeZone.getTimeZone(time.zone), showSeconds) fun getOffsetMillisByZoneId(timeZoneId: String): Int { val zone = TimeZone.getTimeZone(timeZoneId) @@ -194,6 +194,18 @@ object TimeHelper { } } + /** + * Names a timer after the duration it was set to, as in "1h 30m 5s". + */ + fun durationToName(seconds: Int): String { + return listOf( + seconds / 3600 to "h", + seconds % 3600 / 60 to "m", + seconds % 60 to "s" + ).filter { (value, _) -> value > 0 } + .joinToString(" ") { (value, unit) -> "$value$unit" } + } + /** * Method that formats a Duration object into a verbose string to be displayed in the UI */ diff --git a/app/src/main/java/com/bnyro/clock/util/VolumeRamp.kt b/app/src/main/java/com/bnyro/clock/util/VolumeRamp.kt new file mode 100644 index 000000000..5c66a2db3 --- /dev/null +++ b/app/src/main/java/com/bnyro/clock/util/VolumeRamp.kt @@ -0,0 +1,43 @@ +package com.bnyro.clock.util + +import android.media.MediaPlayer +import android.os.Handler +import android.os.Looper + +/** + * The rise a ringing sound takes from its quietest to its loudest over the time the reader asked + * for, so that an alarm or a timer can arrive gently rather than all at once. A rise of no time is + * no rise at all: the sound starts where it means to stay. + */ +class VolumeRamp(private val player: MediaPlayer, private val seconds: Int) { + private val handler = Handler(Looper.getMainLooper()) + private var volume = START_VOLUME + + private val rise = object : Runnable { + override fun run() { + player.setVolume(volume, volume) + if (volume >= MAX_VOLUME) return + + volume = (volume + STEP).coerceAtMost(MAX_VOLUME) + handler.postDelayed(this, seconds * 1000L / STEPS) + } + } + + fun start() { + if (seconds <= 0) return + + volume = START_VOLUME + handler.post(rise) + } + + fun cancel() { + handler.removeCallbacks(rise) + } + + companion object { + private const val START_VOLUME = 0.1f + private const val MAX_VOLUME = 1.0f + private const val STEP = 0.05f + private const val STEPS = 18 + } +} diff --git a/app/src/main/java/com/bnyro/clock/util/services/AlarmService.kt b/app/src/main/java/com/bnyro/clock/util/services/AlarmService.kt index aa0fd39a1..18e26a9ec 100644 --- a/app/src/main/java/com/bnyro/clock/util/services/AlarmService.kt +++ b/app/src/main/java/com/bnyro/clock/util/services/AlarmService.kt @@ -13,9 +13,7 @@ import android.media.MediaPlayer import android.media.RingtoneManager import android.net.Uri import android.os.Build -import android.os.Handler import android.os.IBinder -import android.os.Looper import android.os.Vibrator import android.util.Log import androidx.annotation.RequiresApi @@ -30,11 +28,15 @@ import com.bnyro.clock.R import com.bnyro.clock.domain.model.Alarm import com.bnyro.clock.domain.model.Permission import com.bnyro.clock.presentation.screens.alarm.AlarmActivity +import com.bnyro.clock.presentation.screens.ringing.RingingActivity import com.bnyro.clock.ui.MainActivity import com.bnyro.clock.util.AlarmHelper import com.bnyro.clock.util.NotificationHelper import com.bnyro.clock.util.Preferences import com.bnyro.clock.util.TimeHelper +import com.bnyro.clock.util.VolumeRamp +import com.bnyro.clock.util.widgets.TextColor +import com.bnyro.clock.util.widgets.getColorValue import kotlinx.coroutines.runBlocking import java.util.Timer import java.util.TimerTask @@ -45,25 +47,15 @@ class AlarmService : Service() { private var vibrator: Vibrator? = null private var mediaPlayer: MediaPlayer? = null private var currentAlarm: Alarm? = null + private var alertScreenVisible = false val timer = Timer() - private var volume: Float = 0.1f - - private val volumeHandler = Handler(Looper.getMainLooper()) - private val volumeRunnable = object : Runnable { - override fun run() { - if (volume < MAX_VOLUME) { - mediaPlayer!!.setVolume(volume, volume) - volume += VOLUME_INCREASE_STEP - volumeHandler.postDelayed(this, VOLUME_INCREASE_INTERVAL) - } - } - } + private var volumeRamp: VolumeRamp? = null private val alarmActionReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { Log.d("AlarmService", "magga") - when (intent?.getStringExtra(ACTION_EXTRA_KEY)) { + when (val action = intent?.getStringExtra(ACTION_EXTRA_KEY)) { DISMISS_ACTION -> { //maybe fixes a super shitty bug that was shitty kinda D: currentAlarm?.let { alarm -> @@ -75,6 +67,15 @@ class AlarmService : Service() { AlarmHelper.snooze(this@AlarmService, currentAlarm!!) stopSelf() } + ALERT_SHOWN_ACTION, ALERT_HIDDEN_ACTION -> { + alertScreenVisible = action == ALERT_SHOWN_ACTION + currentAlarm?.let { + startForeground( + notificationId, + createNotification(this@AlarmService, it) + ) + } + } } } } @@ -210,7 +211,10 @@ class AlarmService : Service() { player.setAudioAttributes(NotificationHelper.audioAttributes) player.prepare() player.start() - volumeHandler.post(volumeRunnable) + volumeRamp = VolumeRamp( + player, + Preferences.instance.getInt(Preferences.alarmVolumeRampSecondsKey, 0) + ).apply { start() } } /** * Stops alarm @@ -219,8 +223,8 @@ class AlarmService : Service() { if (!isPlaying) return isPlaying = false - volumeHandler.removeCallbacks(volumeRunnable) - volume = 0.1f + volumeRamp?.cancel() + volumeRamp = null // Stop audio playing if (mediaPlayer != null) { mediaPlayer?.stop() @@ -232,7 +236,7 @@ class AlarmService : Service() { vibrator?.cancel() val closeAlarmAlertIntent = Intent(AlarmActivity.ALARM_ALERT_CLOSE_ACTION).apply { - putExtra(AlarmActivity.ACTION_EXTRA_KEY, AlarmActivity.CLOSE_ACTION) + putExtra(RingingActivity.ACTION_EXTRA_KEY, RingingActivity.CLOSE_ACTION) `package` = packageName } sendBroadcast(closeAlarmAlertIntent) @@ -298,10 +302,13 @@ class AlarmService : Service() { } ?: context.getString(R.string.ringing_alarm, formattedTime) ) setAutoCancel(true) + setColorized(true) + setColor(TextColor.PrimaryDark.getColorValue(context)) priority = NotificationCompat.PRIORITY_MAX foregroundServiceBehavior = FOREGROUND_SERVICE_IMMEDIATE setCategory(NotificationCompat.CATEGORY_ALARM) - setFullScreenIntent(pendingIntent, true) + setSilent(alertScreenVisible) + if (!alertScreenVisible) setFullScreenIntent(pendingIntent, true) if (alarm.snoozeEnabled) { val snoozeIntent = Intent(ALARM_INTENT_ACTION) .putExtra(ACTION_EXTRA_KEY, SNOOZE_ACTION) @@ -332,11 +339,10 @@ class AlarmService : Service() { const val ACTION_EXTRA_KEY = "action" const val DISMISS_ACTION = "DISMISS" const val SNOOZE_ACTION = "SNOOZE" + const val ALERT_SHOWN_ACTION = "ALERT_SHOWN" + const val ALERT_HIDDEN_ACTION = "ALERT_HIDDEN" const val ALARM_TIMEOUT_MINUTES = 10 private const val MISSED_ALARM_ID_OFFSET = 8000 - private const val MAX_VOLUME: Float = 1.0f - private const val VOLUME_INCREASE_STEP: Float = 0.05f - private const val VOLUME_INCREASE_INTERVAL: Long = 1000 } } diff --git a/app/src/main/java/com/bnyro/clock/util/services/StopwatchService.kt b/app/src/main/java/com/bnyro/clock/util/services/StopwatchService.kt index c2ec45454..ed6fb42bf 100644 --- a/app/src/main/java/com/bnyro/clock/util/services/StopwatchService.kt +++ b/app/src/main/java/com/bnyro/clock/util/services/StopwatchService.kt @@ -65,7 +65,9 @@ class StopwatchService : Service() { contentIntent = PendingIntent.getActivity( this, 8, - Intent(this, MainActivity::class.java).setAction(MainActivity.SHOW_STOPWATCH_ACTION), + Intent(this, MainActivity::class.java) + .setAction(MainActivity.SHOW_STOPWATCH_ACTION) + .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) diff --git a/app/src/main/java/com/bnyro/clock/util/services/TimerService.kt b/app/src/main/java/com/bnyro/clock/util/services/TimerService.kt index 3e1908368..145e4da83 100644 --- a/app/src/main/java/com/bnyro/clock/util/services/TimerService.kt +++ b/app/src/main/java/com/bnyro/clock/util/services/TimerService.kt @@ -3,6 +3,7 @@ package com.bnyro.clock.util.services import android.Manifest import android.annotation.SuppressLint import android.app.AlarmManager +import android.app.Notification import android.app.PendingIntent import android.app.Service import android.content.BroadcastReceiver @@ -21,20 +22,30 @@ import android.os.Looper import android.os.PowerManager import android.os.SystemClock import android.os.Vibrator +import android.provider.AlarmClock import android.text.format.DateUtils import android.util.Log import androidx.annotation.RequiresApi -import androidx.annotation.StringRes import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat +import androidx.core.app.ServiceCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat +import androidx.core.net.toUri import com.bnyro.clock.R import com.bnyro.clock.domain.model.TimerDescriptor import com.bnyro.clock.domain.model.TimerObject +import com.bnyro.clock.domain.model.TimerSettings import com.bnyro.clock.domain.model.WatchState +import com.bnyro.clock.presentation.screens.ringing.RingingActivity +import com.bnyro.clock.presentation.screens.timer.TimerAlertActivity import com.bnyro.clock.ui.MainActivity import com.bnyro.clock.util.NotificationHelper +import com.bnyro.clock.util.Preferences +import com.bnyro.clock.util.widgets.TextColor +import com.bnyro.clock.util.widgets.getColorValue +import com.bnyro.clock.util.TimeHelper +import com.bnyro.clock.util.VolumeRamp import java.util.Timer import java.util.TimerTask @@ -57,6 +68,12 @@ class TimerService : Service() { var timerObjects = mutableListOf() private var wakeLock: PowerManager.WakeLock? = null + private var alertedTimerId: Int? = null + private var ringingTimerId: Int? = null + private var ringingSince = 0L + private var lastRungSeconds = 0L + private var ringTimeout: TimerTask? = null + private var volumeRamp: VolumeRamp? = null @SuppressLint("ServiceCast", "ScheduleExactAlarm") private fun scheduleAlarm(timerObject: TimerObject) { @@ -102,13 +119,25 @@ class TimerService : Service() { } } + private val fullScreenAlertEnabled + get() = Preferences.instance.getBoolean(Preferences.timerFullScreenAlertKey, true) + + private val timeoutMinutes + get() = Preferences.instance.getInt( + Preferences.timerTimeoutMinutesKey, + TIMER_TIMEOUT_MINUTES + ) + + /** How long the timer that is ringing has been ringing for. */ + private val ringDuration get() = System.currentTimeMillis() - ringingSince + private val receiver = object : BroadcastReceiver() { @RequiresApi(Build.VERSION_CODES.N) override fun onReceive(context: Context, intent: Intent) { Log.e("receive", intent.toString()) val id = intent.getIntExtra(ID_EXTRA_KEY, 0) val obj = timerObjects.find { it.id == id } ?: return - when (intent.getStringExtra(ACTION_EXTRA_KEY)) { + when (val action = intent.getStringExtra(ACTION_EXTRA_KEY)) { ACTION_STOP -> { stop(obj, cancelled = true) } @@ -117,28 +146,61 @@ class TimerService : Service() { if (obj.state.value == WatchState.PAUSED) resume(obj) else pause(obj) } - ACTION_ADD_5_MIN -> { - obj.currentPosition.value += 300000 + ACTION_ALERT_SHOWN, ACTION_ALERT_HIDDEN -> { + alertedTimerId = obj.id.takeIf { action == ACTION_ALERT_SHOWN } + if (obj.id == ringingTimerId) { + promoteForeground(announcing = action == ACTION_ALERT_HIDDEN) + } + } + + ACTION_ADD_TIME -> { + // a timer that has finished ringing has run out of time to add to, so the + // time added starts it running again rather than sitting on a finished timer + val finished = obj.currentPosition.value == 0 + if (finished) { + endRinging(obj) + oldnow = SystemClock.elapsedRealtime() + obj.state.value = WatchState.RUNNING + } + + obj.currentPosition.value += obj.effectiveIncrementSeconds * 1000 + + if (obj.state.value == WatchState.RUNNING) { + cancelAlarm(obj) + scheduleAlarm(obj) + if (finished) acquireWakeLock() + } + + if (finished) { + NotificationManagerCompat.from(context) + .cancel(finishedNotificationId(obj)) + promoteForeground() + invokeChangeListener() + } + updateNotification(obj) } TIMER_RESTART -> { - stopAudio() + endRinging(obj) - oldnow = System.currentTimeMillis() + oldnow = SystemClock.elapsedRealtime() - obj.currentPosition.value = obj.initialPosition - obj.state.value = WatchState.RUNNING + // a finished timer has no run left to return to, so it starts a new one + if (obj.currentPosition.value == 0) obj.state.value = WatchState.RUNNING + obj.currentPosition.value = obj.initialPosition.value cancelAlarm(obj) - scheduleAlarm(obj) - acquireWakeLock() + if (obj.state.value == WatchState.RUNNING) { + scheduleAlarm(obj) + acquireWakeLock() + } - val finishedNotificationId = (Integer.MAX_VALUE / 3) + obj.id * 10 val notificationManager = NotificationManagerCompat.from(context) - notificationManager.cancel(finishedNotificationId) + notificationManager.cancel(finishedNotificationId(obj)) notificationManager.cancel(obj.id) + promoteForeground() invokeChangeListener() updateNotification(obj) } @@ -148,21 +210,22 @@ class TimerService : Service() { private fun play(timerObject: TimerObject) { stopAudio() - val alert: Uri = timerObject.ringtone ?: RingtoneManager.getDefaultUri( - RingtoneManager.TYPE_ALARM - ) + if (timerObject.soundEnabled) { + val alert: Uri = timerObject.soundUri?.toUri() ?: RingtoneManager.getDefaultUri( + RingtoneManager.TYPE_ALARM + ) - mediaPlayer = MediaPlayer() + mediaPlayer = MediaPlayer() - try { - mediaPlayer!!.setDataSource(this, alert) - startAlarm(mediaPlayer!!) - } catch (e: Exception) { - Log.e("failed to play ringtone", e.message, e) + try { + mediaPlayer!!.setDataSource(this, alert) + startAlarm(mediaPlayer!!) + } catch (e: Exception) { + Log.e("failed to play ringtone", e.message, e) + } } if (timerObject.vibrate) { - val pattern = longArrayOf(0, 500, 500) - vibrator.vibrate(pattern, 0) + vibrator.vibrate(timerObject.vibrationPattern.map(Int::toLong).toLongArray(), 0) } else { vibrator.cancel() } @@ -174,6 +237,10 @@ class TimerService : Service() { player.setAudioAttributes(NotificationHelper.audioAttributes) player.prepare() player.start() + volumeRamp = VolumeRamp( + player, + Preferences.instance.getInt(Preferences.timerVolumeRampSecondsKey, 0) + ).apply { start() } } /** @@ -183,6 +250,9 @@ class TimerService : Service() { if (!isPlaying) return isPlaying = false + volumeRamp?.cancel() + volumeRamp = null + if (mediaPlayer != null) { mediaPlayer?.stop() mediaPlayer?.release() @@ -211,7 +281,9 @@ class TimerService : Service() { contentIntent = PendingIntent.getActivity( this, 0, - Intent(this, MainActivity::class.java), + Intent(this, MainActivity::class.java) + .setAction(AlarmClock.ACTION_SHOW_TIMERS) + .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) @@ -229,7 +301,10 @@ class TimerService : Service() { }, 0, UPDATE_DELAY.toLong() ) ContextCompat.registerReceiver( - this, receiver, IntentFilter(UPDATE_STATE_ACTION), ContextCompat.RECEIVER_EXPORTED + this, + receiver, + IntentFilter(UPDATE_STATE_ACTION).apply { addDataScheme(UPDATE_STATE_SCHEME) }, + ContextCompat.RECEIVER_EXPORTED ) } @@ -243,25 +318,18 @@ class TimerService : Service() { Log.e("TimerService", "error D:D:D:DD:D:D:D:D:D:") NotificationManagerCompat.from(this).cancel(id) if (timerObjects.isEmpty()) { - stopForeground(STOP_FOREGROUND_REMOVE) + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) stopSelf() } return START_STICKY } - play(obj) - - val notificationManager = NotificationManagerCompat.from(this) - notificationManager.cancel(obj.id) - - if (timerObjects.size <= 1) { - stopForeground(STOP_FOREGROUND_REMOVE) - } - - showFinishedNotification(obj) + cancelAlarm(obj) obj.currentPosition.value = 0 obj.state.value = WatchState.PAUSED + startRinging(obj) + if (timerObjects.none { t -> t.state.value == WatchState.RUNNING }) { releaseWakeLock() } @@ -286,72 +354,64 @@ class TimerService : Service() { return START_STICKY } - private fun getNotification(timerObject: TimerObject) = NotificationCompat.Builder( - this, NotificationHelper.TIMER_CHANNEL - ).setContentTitle( - timerObject.label.value?.takeIf { it.isNotBlank() }?.let { - getString( + private fun getNotification(timerObject: TimerObject): Notification { + val timeLeft = DateUtils.formatElapsedTime(timerObject.secondsLeft.toLong()) + + return NotificationCompat.Builder( + this, NotificationHelper.TIMER_CHANNEL + ).setContentTitle(timeLeft) + .setShortCriticalText(timeLeft) + .setRequestPromotedOngoing(true) + .setContentText( if (timerObject.state.value == WatchState.RUNNING) { - R.string.running_named_timer + timerObject.label.value } else { - R.string.paused_named_timer - }, - it + getString(R.string.paused_timer_title, timerObject.label.value) + } ) - } ?: getString( - if (timerObject.state.value == WatchState.RUNNING) { - R.string.running_timer - } else { - R.string.paused_timer - } - ) - ) - .setContentIntent(contentIntent) - .apply { - if (timerObject.state.value == WatchState.RUNNING) { - setUsesChronometer(true) - setWhen(System.currentTimeMillis() + timerObject.currentPosition.value) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - setChronometerCountDown(true) + .setContentIntent(contentIntent) + .setShowWhen(false) + .setOnlyAlertOnce(true) + .addAction(pauseResumeAction(timerObject)) + .addAction( + if (timerObject.state.value == WatchState.RUNNING) { + addTimeAction(timerObject) } else { - setContentText( - DateUtils.formatElapsedTime( - (timerObject.currentPosition.value / 1000).toLong() - ) - ) + resetAction(timerObject) } - } else { - setContentText( - DateUtils.formatElapsedTime( - (timerObject.currentPosition.value / 1000).toLong() - ) - ) - setShowWhen(false) - } - } - .addAction(stopAction(timerObject)).addAction(pauseResumeAction(timerObject)) - .addAction(restarttimer(timerObject)).addAction(add5MinAction(timerObject)) - .setSmallIcon(R.drawable.ic_notification).setOngoing(true).build() + ) + .addAction(stopAction(timerObject)) + .setSmallIcon(R.drawable.ic_timer).setOngoing(true).build() + } fun invokeChangeListener() { onChangeTimers.invoke(timerObjects.toTypedArray()) } private fun updateState() { - val now = System.currentTimeMillis() + val now = SystemClock.elapsedRealtime() val delta = now - oldnow oldnow = now timerObjects.forEach { if (it.state.value == WatchState.RUNNING) { + val before = it.secondsLeft it.currentPosition.value = (it.currentPosition.value - delta.toInt()).coerceAtLeast(0) - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + if (before != it.secondsLeft) { updateNotification(it) } } } + + timerObjects.find { it.id == ringingTimerId }?.let { + val rung = ringDuration / 1000 + if (rung != lastRungSeconds) { + lastRungSeconds = rung + showFinishedNotification(it) + } + } } fun enqueueNew(timerObject: TimerObject) { @@ -395,7 +455,7 @@ class TimerService : Service() { @RequiresApi(Build.VERSION_CODES.N) private fun stop(timerObject: TimerObject, cancelled: Boolean) { cancelAlarm(timerObject) - stopAudio() + endRinging(timerObject) timerObjects.remove(timerObject) if (timerObjects.none { it.state.value == WatchState.RUNNING }) { @@ -403,30 +463,142 @@ class TimerService : Service() { } invokeChangeListener() + promoteForeground() val notificationManager = NotificationManagerCompat.from(this) notificationManager.cancel(timerObject.id) - val finishedNotificationId = (Integer.MAX_VALUE / 3) + timerObject.id * 10 - notificationManager.cancel(finishedNotificationId) + notificationManager.cancel(finishedNotificationId(timerObject)) if (timerObjects.isEmpty()) { - stopForeground(STOP_FOREGROUND_REMOVE) stopSelf() } } - private fun showFinishedNotification(timerObject: TimerObject) { + /** + * A timer that has run out takes over the ringing from whichever timer was ringing before it, + * which keeps its own record of how long it rang for. The screen the earlier timer was showing + * is left standing for the new timer's intent to take over rather than closed and reopened. + */ + private fun startRinging(timerObject: TimerObject) { + val silenced = timerObjects.find { it.id == ringingTimerId } + ?.let { it to endRinging(it, keepAlert = true) } + + ringingTimerId = timerObject.id + ringingSince = System.currentTimeMillis() + lastRungSeconds = 0 + NotificationManagerCompat.from(this).cancel(timerObject.id) + play(timerObject) + + ringTimeout = object : TimerTask() { + override fun run() { + handler.post { + val rangFor = endRinging(timerObject) + promoteForeground() + showFinishedNotification(timerObject, rangFor) + } + } + }.also { timer.schedule(it, timeoutMinutes * 60 * 1000L) } + + promoteForeground(announcing = true) + // the silenced timer's last word is posted only after the foreground has moved to the new + // timer, because a notification still bound as the foreground one is removed by that move + silenced?.let { (ringing, rangFor) -> showFinishedNotification(ringing, rangFor) } + if (fullScreenAlertEnabled) startActivity(alertIntent(timerObject)) + } + + private fun endRinging(timerObject: TimerObject, keepAlert: Boolean = false): Long { + if (ringingTimerId != timerObject.id) return 0 + + val rangFor = ringDuration + stopAudio() + ringTimeout?.cancel() + ringTimeout = null + ringingTimerId = null + if (!keepAlert) closeAlert(timerObject) + return rangFor + } + + /** + * The notification the service is held in the foreground by is the one the reader most needs to + * see: the timer that is ringing, or failing that whichever timer is still counting. + */ + private fun promoteForeground(announcing: Boolean = false) { + val ringing = timerObjects.find { it.id == ringingTimerId } + if (ringing != null) { + startForeground( + finishedNotificationId(ringing), + finishedNotification(ringing, announcing = announcing) + ) + return + } + + val counting = timerObjects.firstOrNull { it.state.value == WatchState.RUNNING } + if (counting != null) { + startForeground(counting.id, getNotification(counting)) + } else { + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) + } + } + + private fun finishedNotificationId(timerObject: TimerObject) = + (Integer.MAX_VALUE / 3) + timerObject.id * 10 + + /** + * The screen a finished timer takes over the phone with, named after the timer it belongs to so + * that whichever timer is showing can be answered on its own. + */ + private fun alertIntent(timerObject: TimerObject) = + Intent(this, TimerAlertActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NO_USER_ACTION) + .putExtra(ID_EXTRA_KEY, timerObject.id) + .putExtra(LABEL_EXTRA_KEY, timerObject.label.value) + .putExtra(RINGING_SINCE_EXTRA_KEY, ringingSince) + .putExtra(INCREMENT_EXTRA_KEY, timerObject.effectiveIncrementSeconds) + + private fun closeAlert(timerObject: TimerObject) { + if (alertedTimerId == timerObject.id) alertedTimerId = null + sendBroadcast( + Intent(TIMER_ALERT_CLOSE_ACTION) + .putExtra(RingingActivity.ACTION_EXTRA_KEY, RingingActivity.CLOSE_ACTION) + .putExtra(ID_EXTRA_KEY, timerObject.id) + .setPackage(packageName) + ) + } + + private fun showFinishedNotification( + timerObject: TimerObject, + rangFor: Long? = null, + announcing: Boolean = false + ) { if (ActivityCompat.checkSelfPermission( this, Manifest.permission.POST_NOTIFICATIONS ) != PackageManager.PERMISSION_GRANTED ) return - val notificationChannelId = NotificationHelper.TIMER_FINISHED_CHANNEL - val finishedNotificationId = (Integer.MAX_VALUE / 3) + timerObject.id * 10 + NotificationManagerCompat.from(this).notify( + finishedNotificationId(timerObject), + finishedNotification(timerObject, rangFor, announcing) + ) + } - val stopIntent = Intent(UPDATE_STATE_ACTION).apply { - putExtra(ACTION_EXTRA_KEY, ACTION_STOP) - putExtra(ID_EXTRA_KEY, timerObject.id) - } + /** + * What a finished timer says for itself. While it is ringing it goes on counting, past zero and + * into the time it has been waiting to be answered; once the ringing is over it keeps the count + * it stopped at as the length it rang for, and gives its title back to the app. + */ + private fun finishedNotification( + timerObject: TimerObject, + rangFor: Long? = null, + announcing: Boolean = false + ): Notification { + val notificationChannelId = NotificationHelper.TIMER_FINISHED_CHANNEL + val finishedNotificationId = finishedNotificationId(timerObject) + val ringing = rangFor == null + val alertShowing = alertedTimerId == timerObject.id + // the ring announces itself once, and again whenever the screen that was answering for it + // goes away; the counting that follows is the same announcement wearing a newer number + val announces = ringing && announcing && !alertShowing + + val stopIntent = updateStateIntent(ACTION_STOP, timerObject.id) val stopPendingIntent = PendingIntent.getBroadcast( this, finishedNotificationId, @@ -437,10 +609,7 @@ class TimerService : Service() { null, getString(R.string.stop), stopPendingIntent ).build() - val restartIntent = Intent(UPDATE_STATE_ACTION).apply { - putExtra(ACTION_EXTRA_KEY, TIMER_RESTART) - putExtra(ID_EXTRA_KEY, timerObject.id) - } + val restartIntent = updateStateIntent(TIMER_RESTART, timerObject.id) val restartPendingIntent = PendingIntent.getBroadcast( this, finishedNotificationId + 2, @@ -448,89 +617,126 @@ class TimerService : Service() { PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) val restartAction = NotificationCompat.Action.Builder( - null, getString(R.string.timer_restart), restartPendingIntent + null, getString(R.string.timer_reset), restartPendingIntent ).build() - cancelAlarm(timerObject) + val snoozeIntent = updateStateIntent(ACTION_ADD_TIME, timerObject.id) + val snoozePendingIntent = PendingIntent.getBroadcast( + this, + finishedNotificationId + 3, + snoozeIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + val snoozeAction = NotificationCompat.Action.Builder( + null, addTimeLabel(timerObject.effectiveIncrementSeconds), snoozePendingIntent + ).build() - val deleteIntent = Intent(UPDATE_STATE_ACTION).apply { - putExtra(ACTION_EXTRA_KEY, ACTION_STOP) - putExtra(ID_EXTRA_KEY, timerObject.id) - } + val alertPendingIntent = PendingIntent.getActivity( + this, + finishedNotificationId + 4, + alertIntent(timerObject), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val deleteIntent = updateStateIntent(ACTION_STOP, timerObject.id) val deletePendingIntent = PendingIntent.getBroadcast( this, finishedNotificationId + 1, deleteIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) - val notification = NotificationCompat.Builder(this, notificationChannelId) - .setSmallIcon(R.drawable.ic_notification) - .setSilent(true) + return NotificationCompat.Builder(this, notificationChannelId) + .setSmallIcon(R.drawable.ic_timer) .setContentTitle( - timerObject.label.value?.takeIf { it.isNotBlank() }?.let { - getString(R.string.finished_named_timer, it) - } ?: getString(R.string.timer_finished) + if (ringing) "-" + DateUtils.formatElapsedTime(ringDuration / 1000) else null + ) + .setContentText( + if (ringing) { + getString(R.string.finished_named_timer, timerObject.label.value) + } else { + getString( + R.string.finished_named_timer_for, + timerObject.label.value, + TimeHelper.durationToName((rangFor / 1000).toInt()) + ) + } ) .setContentIntent(contentIntent) .setCategory(NotificationCompat.CATEGORY_ALARM) .setPriority(NotificationCompat.PRIORITY_MAX) .setDeleteIntent(deletePendingIntent) .setOngoing(false) + .setSilent(!announces) + .apply { + if (ringing) { + setColorized(true) + setColor(TextColor.PrimaryDark.getColorValue(this@TimerService)) + } + if (announces && fullScreenAlertEnabled) { + setFullScreenIntent(alertPendingIntent, true) + } + } .addAction(stopAction) + .addAction(snoozeAction) .addAction(restartAction) - .build().apply { - flags = flags or NotificationCompat.FLAG_INSISTENT - } - - NotificationManagerCompat.from(this).notify(finishedNotificationId, notification) + .build() } private fun pauseResumeAction(timerObject: TimerObject): NotificationCompat.Action { val text = if (timerObject.state.value == WatchState.PAUSED) R.string.resume else R.string.pause - return getAction(text, ACTION_PAUSE_RESUME, 5, timerObject.id) + return getAction(getString(text), ACTION_PAUSE_RESUME, 5, timerObject.id) } private fun getAction( - @StringRes stringRes: Int, action: String, requestCode: Int, objectId: Int + label: String, action: String, requestCode: Int, objectId: Int ): NotificationCompat.Action { - val intent = Intent(UPDATE_STATE_ACTION).putExtra(ACTION_EXTRA_KEY, action) - .putExtra(ID_EXTRA_KEY, objectId) val pendingIntent = PendingIntent.getBroadcast( this, requestCode + objectId, - intent, + updateStateIntent(action, objectId), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) - return NotificationCompat.Action.Builder(null, getString(stringRes), pendingIntent).build() + return NotificationCompat.Action.Builder(null, label, pendingIntent).build() } private fun stopAction(timerObject: TimerObject) = getAction( - R.string.stop, ACTION_STOP, 4, timerObject.id + getString(R.string.stop), ACTION_STOP, 4, timerObject.id ) - private fun restarttimer(timerObject: TimerObject) = getAction( - R.string.timer_restart, TIMER_RESTART, 7, timerObject.id + private fun resetAction(timerObject: TimerObject) = getAction( + getString(R.string.timer_reset), TIMER_RESTART, 7, timerObject.id ) - private fun add5MinAction(timerObject: TimerObject) = getAction( - R.string.add_5_minutes, ACTION_ADD_5_MIN, 6, timerObject.id + private fun addTimeAction(timerObject: TimerObject) = getAction( + addTimeLabel(timerObject.effectiveIncrementSeconds), + ACTION_ADD_TIME, + 6, + timerObject.id ) - fun updateLabel(id: Int, newLabel: String) { - timerObjects.firstOrNull { it.id == id }?.let { - it.label.value = newLabel - updateNotification(it) - } - } - - fun updateRingtone(id: Int, newRingtoneUri: Uri?) { - timerObjects.firstOrNull { it.id == id }?.let { - it.ringtone = newRingtoneUri - } + private fun addTimeLabel(seconds: Int) = if (seconds == 60) { + getString(R.string.add_one_minute) + } else { + resources.getQuantityString(R.plurals.add_seconds, seconds, seconds) } - fun updateVibrate(id: Int, vibrate: Boolean) { + fun updateTimer(id: Int, settings: TimerSettings) { timerObjects.firstOrNull { it.id == id }?.let { - it.vibrate = vibrate + val duration = settings.seconds * 1000 + val running = it.state.value == WatchState.RUNNING + it.currentPosition.value = + (it.currentPosition.value.toLong() * duration / it.initialPosition.value).toInt() + if (running) cancelAlarm(it) + it.label.value = settings.label + it.initialPosition.value = duration + it.soundName = settings.soundName + it.soundUri = settings.soundUri + it.soundEnabled = settings.soundEnabled + it.vibrate = settings.vibrate + it.vibrationPattern = settings.vibrationPattern + it.vibrationPatternName = settings.vibrationPatternName + it.incrementSeconds = settings.incrementSeconds + if (running) scheduleAlarm(it) + updateNotification(it) } } @@ -558,7 +764,21 @@ class TimerService : Service() { const val ACTION_STOP = "stop" private const val UPDATE_DELAY = 100 const val TIMER_RESTART = "timer_restart" - const val ACTION_ADD_5_MIN = "add_5_min" + const val ACTION_ADD_TIME = "add_time" + const val UPDATE_STATE_SCHEME = "timer" + const val ACTION_ALERT_SHOWN = "alert_shown" + const val ACTION_ALERT_HIDDEN = "alert_hidden" + const val LABEL_EXTRA_KEY = "label" + const val RINGING_SINCE_EXTRA_KEY = "ringing_since" + const val INCREMENT_EXTRA_KEY = "increment" + const val TIMER_TIMEOUT_MINUTES = 10 + const val TIMER_ALERT_CLOSE_ACTION = "com.bnyro.clock.TIMER_ALERT_CLOSE_ACTION" + + fun updateStateIntent(action: String, objectId: Int): Intent = + Intent(UPDATE_STATE_ACTION) + .setData("$UPDATE_STATE_SCHEME://$objectId/$action".toUri()) + .putExtra(ACTION_EXTRA_KEY, action) + .putExtra(ID_EXTRA_KEY, objectId) const val ACTION_TIMER_EXPIRED = "com.bnyro.clock.TIMER_EXPIRED" } } diff --git a/app/src/main/res/drawable/ic_timer.xml b/app/src/main/res/drawable/ic_timer.xml new file mode 100644 index 000000000..9ca445fc9 --- /dev/null +++ b/app/src/main/res/drawable/ic_timer.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index a5d8b6bbf..dabf89041 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -46,7 +46,6 @@ المدة: %1$s أبجدي قيمة مباعدة - عرض التحديد السريع ساعات دقائق ثواني @@ -55,7 +54,6 @@ نفس الوقت تكرار إعادة البدء - ٥ دقائق عناصر التبديل الساعة @@ -94,7 +92,6 @@ مرة واحدة أيام الأسبوع حدد مدة الغفوة - تكرار اسم التنبيه كاتبوتشين ‬أموليد @@ -120,7 +117,6 @@ عرض التاريخ حفظ حجم نص التاريخ - إضافة مؤقت مسبق بدء حذف الساعة العالمية البحث عن البلد أو المنطقة الزمنية @@ -152,7 +148,6 @@ النسخ الاحتياطي للمنبهات إلى ملف JSON الترحيل المنبه القادم - استخدام زر البداية الكبير سينطلق في %1$s تخطي أحمر @@ -195,10 +190,6 @@ لوحة الأرقام ساعة التوقيف مُتوقفة مؤقتًا ساعة التوقيف قيد التشغيل - المؤقت مُتوقف مؤقتًا - المؤقت قيد التشغيل - مؤقت %1$s قيد التشغيل - مؤقت %1$s مُتوقف مؤقتًا انتهى مؤقت %1$s أنه رن لمدة %d دقيقة diff --git a/app/src/main/res/values-az/strings.xml b/app/src/main/res/values-az/strings.xml index e92b9d9a5..c5301e8bb 100644 --- a/app/src/main/res/values-az/strings.xml +++ b/app/src/main/res/values-az/strings.xml @@ -60,14 +60,12 @@ Ertələmə vaxtını seç Siqnal Adı Bir Dəfə - Təkrarlanan Həftə sonları Həftə içi Müddət: %1$s Sil Ertələ İmtina et - Taymer sürətli seçimin göstər Saat Saniyə Dövrə @@ -78,7 +76,6 @@ %d saat geridə Tarix Mətn Ölçüsü - İlkin qurulan taymer əlavə et %d saat iləri %d saat iləri diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml index becb5a542..2f7ab7cbb 100644 --- a/app/src/main/res/values-be/strings.xml +++ b/app/src/main/res/values-be/strings.xml @@ -45,7 +45,6 @@ Запусціць таймер\? Працягласць: %1$s Выдаліць - Паказваць хуткі выбар таймера Гадзіны Секунды Хвіліны diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index 30853c410..392d822b0 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -48,7 +48,6 @@ Часове Минути Секунди - Бърз избор на отброяване Отхвърляне Отлагане Същото време @@ -73,7 +72,6 @@ Еднократно Работни дни Период на отлагане - Повтарящо се Име на будилника Catppuccin Черна @@ -95,7 +93,6 @@ Показване на датата Запазване Приспособление за цифров часовник - Добавете предварително зададен таймер Старт Дата Размер на текста Разрешете известия, за да сте сигурни, че никога няма да пропуснете аларма или таймер. Ще изпращаме известия само за вашите зададени аларми и таймери. diff --git a/app/src/main/res/values-bo/strings.xml b/app/src/main/res/values-bo/strings.xml index e1f3cf35f..11fed42c9 100644 --- a/app/src/main/res/values-bo/strings.xml +++ b/app/src/main/res/values-bo/strings.xml @@ -33,7 +33,6 @@ སོར་བཞག། དུས་ཚོད་འགོ་འཛུགས་ཡ་ཡིན་ནམ། དུས་ཡུན།: %1$s - སྐར་མ་ལྔ། བསྐྱར་དུ་འགོ་འཛུགས། ཀ་མད་ལྟར། མཐོང་རྣམ། @@ -58,7 +57,6 @@ གཉིད་དུས་འདེམས་རོགས། དྲིལ་བརྡའི་མིང་། ཐེངས་གཅིག། - བསྐྱར་ཟློས། གཟའ་མཇུག། བདུན་ཞག། དུས་ཚོད་ %1$s འདིའི་ནང་དྲིལ་བརྡ་འགོ་འཛུགས། diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 9b18ef04e..50e5289f2 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -45,7 +45,6 @@ Rellotge digital Rellotge vertical Suprimeix - Mostra la selecció ràpida del temporitzador Hores %d minut @@ -60,7 +59,6 @@ Trieu el temps de repetició Nom de l\'alarma Un sol cop - Repetició Caps de setmana Dies feiners L\'alarma començarà en %1$s diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 56c247283..6a8c697ea 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -45,7 +45,6 @@ Odstranit Offset Abecedně - Zobrazit rychlý výběr časovače Hodiny Minuty Sekundy @@ -77,7 +76,6 @@ Jednou Pracovní dny Vyberte čas odložení - Opakovaně Název budíku Catppuccin Amoled @@ -100,7 +98,6 @@ Uložit Velikost textu data Start - Přidat předvolbu časovače Hledat zemi / časové pásmo Odstranit světové hodiny Velikost textu času @@ -124,7 +121,6 @@ Přidat %d minut Barva textu data Barva textu času - 5 min Restart Hodiny Zkontrolujte to ještě jednou! @@ -133,7 +129,6 @@ Toto oprávnění je nutné, aby časovače fungovaly i při vypnutém displeji telefonu. Odstranit budík(y) Přepnout položky - Použít velké tlačítko start Export budíků záloha budíků do souboru json Nadcházející budík diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 3e0d02b43..7fb304829 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -26,13 +26,11 @@ Slumre Afvis Samme tid - Vis hurtigvalg af timer Gentag Vælg Slumretid Alarmnavn Weekender Hverdage - Gentagende Start-fane Alarmen starter om %1$s. Amoled @@ -106,7 +104,6 @@ Slumre %d minutter Vis Widget-baggrund Brug en anden tidszone til widget\'en - Tilføj forudindstillet timer Start Tilføj %d minutter Slet Verdensur diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 3fdf447ca..d86b0b329 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -52,7 +52,6 @@ Stunden Minuten Sekunden - Timer-Schnellauswahl anzeigen Verwerfen Schlummern Gleiche Zeit @@ -79,7 +78,6 @@ Snooze-Zeit auswählen Name des Alarms Einmal - Wiederholend Wochenende Wochentage S @@ -90,7 +88,6 @@ S Datum anzeigen Speichern - Voreingestellten Timer hinzufügen Datumstextgröße M Von @@ -122,7 +119,6 @@ Um Sie rechtzeitig aufzuwecken braucht Clock You die Erlaubnis, Wecker zu planen. Alarmberechtigung %d Minuten schlummern - 5 Min Neustart Elemente umschalten Uhr @@ -133,7 +129,6 @@ Diese Berechtigung wird benötigt, damit Timer funktionieren, wenn der Telefonbildschirm ausgeschaltet ist. Nochmal überprüfen! Lösche Alarm(e) - Nutze große Start Taste Exportiere Alarme Sichere Alarme zur Json Datei Kommende Alarme diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 446e07cb0..df5e24aff 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -2,7 +2,6 @@ Υπηρεσία χρονοδιακόπτη Μία φορά - Εμφάνιση γρήγορης επιλογής χρονοδιακόπτη %d ώρα και %d λεπτά πίσω %d ώρες και %d λεπτά πίσω @@ -58,7 +57,6 @@ Ίδια ώρα Επανάληψη Όνομα ξυπνητηριού - Επαναλαμβανόμενη Σαββατοκύριακα Το ξυπνητήρι θα ξεκινήσει σε %1$s. Χρωματικό σχέδιο @@ -90,7 +88,6 @@ Εμφάνιση ημερομηνίας Αποθήκευση Μέγεθος κειμένου Ημερομηνίας - Προσθήκη προκαθορισμένου χρονοδιακόπτη Έναρξη Μέγεθος κειμένου Χρόνου Ζώνη ώρας diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 7de654cf3..64dbeb303 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -45,7 +45,6 @@ Borrar Compensar Alfabético - Mostrar la selección rápida para el temporizador Horas Minutos Segundos @@ -78,7 +77,6 @@ Una vez Días laborables Seleccione el tiempo para la repetición - Repitiendo Nombre de la alarma Catppuccin Amoled @@ -99,7 +97,6 @@ Mostrar fecha Guardar Tamaño del texto de la fecha - Agregar temporizador preestablecido Comenzar Eliminar reloj mundial Buscar país/zona horaria diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 9d4148365..5ce830ad5 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -46,7 +46,6 @@ Numbritega kell Püstine kell Kustuta - Näita taimeri juures kiirvalikuid Tukasta Sama aeg Korda @@ -54,7 +53,6 @@ Katkesta Äratuse nimi Üks kord - Korduv Nädalavahetused Tööpäevad Äratuse aeg on %1$s. @@ -98,7 +96,6 @@ Näita kuupäeva Salvesta Kuupäeva tekstisuurus - Lisa eelseadistatud taimer Kustuta maailma kellaaeg Käivita Otsi riiki või ajavööndit @@ -123,7 +120,6 @@ Vähenda %d minuti võrra Kellaaja teksti värv Kuupäeva teksti värv - Lisa 5 min Käivita uuesti Lülita objektid sisse/välja Kell @@ -138,7 +134,6 @@ Andmete ümbertõstmine Impordi äratused Impordi äratused Fossify\'st - Kasuta suurt käivitusnuppu Järgmine äratus Käivitub %1$s diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 4db7b50bc..6d41e891f 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -50,7 +50,6 @@ ساعت راست‌ایستا پاک‌کردن ساعت‌ها - تکرار درنگ ادامه سرنام @@ -64,7 +63,6 @@ فاصله نمایش ثانیه‌ها نمایش دستور بن‌مایه‌ی برنامه - نمایش دست‌چین بیدرنگ زمان‌سنج واپَساندن رد‌کردن هم‌زمان @@ -91,7 +89,6 @@ زمان همادی نمایش تاریخ اندازه نوشتار تاریخ - افزودن زمان‌سنج پیش‌ساخت پاک‌کردن ساعت جهانی جستجو کشور/گستره‌ی زمانی اندازه‌ی نوشتار زمان diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 724651515..b7428fce2 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -46,7 +46,6 @@ Kesto: %1$s Siirtymä Minuutit - Näytä ajastimen pikavalinta Tunnit Sekunnit Hylkää @@ -61,7 +60,6 @@ Kerran Valitse torkutusaika - Toistuva Herätyksen nimi Viikonloput Arkipäivät @@ -100,7 +98,6 @@ Näytä päiväys Tallenna Päiväyksen tekstin koko - Lisää esiasetusajastin Käynnistä Alkaa Päättyy @@ -119,5 +116,4 @@ Aikavyöhyke Time text color https://hosted.weblate.org/translate/you-apps/clock-you/fi/?checksum=8c427160e7103bc6 - 5 Min5 Min diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 9fe5af0a1..2b69b4792 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -46,7 +46,6 @@ Supprimer Lancer le minuteur ? Minutes - Afficher les suggestions rapides pour le minuteur Service de chronomètre Offset Arrêter @@ -77,7 +76,6 @@ Une seule fois Jours de la semaine Sélectionner l\'heure de répétition - Répétition Libellé de l\'alarme Dim Lun @@ -106,7 +104,6 @@ Soustraire %d minutes Afficher la date Sauvegarder - Ajouter une minuterie prédéfinie Commencer Taille de la date Autoriser les notifications pour vous assurer de ne jamais manquer une alarme ou une minuterie. Nous enverrons uniquement des alertes pour les alarmes et minuteries que vous avez définies. @@ -123,7 +120,6 @@ Afficher l\'arrière-plan du widget Afficher l\'heure Supprimer des alarmes - 5 min Redémarrer Horloge Désactiver les optimisations de la batterie @@ -140,6 +136,5 @@ Afficher l\'ombre du texte Importer des alarmes Importer des alarmes depuis Fossify - Utiliser un grand bouton \"Démarrer\" Migration diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 02da58a86..ef930c5d2 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -33,7 +33,6 @@ अनुरूप घड़ी डिजिटल घड़ी मिटाएं - टाइमर त्वरित चयन दिखाएं घंटे मिनट @@ -56,7 +55,6 @@ प्रारंभ टैब से तक - दोहराना %d घंटा और %d मिनट आगे %d घंटे और %d मिनट आगे @@ -98,7 +96,6 @@ दिनांक दिखाएं सहेजें दिनांक पाठ का आकार - पूर्व निर्धारित टाइमर जोड़ें शुरू देश/समयक्षेत्र खोजें विश्व घड़ी मिटाएं diff --git a/app/src/main/res/values-ia/strings.xml b/app/src/main/res/values-ia/strings.xml index b149782ec..7f874f275 100644 --- a/app/src/main/res/values-ia/strings.xml +++ b/app/src/main/res/values-ia/strings.xml @@ -1,7 +1,6 @@ Dimitter - Monstrar le selection rapide del temporisator Horas Modificar le alarma Siesta @@ -54,7 +53,6 @@ Repeter Al mesme hora Seliger tempore de repetition - Repetition Nomine del alarma Fines de septimana Dies de travalio diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 5d085dbdd..c1c7a2296 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -45,7 +45,6 @@ Jam digital Hapus Offset - Tampilkan pilihan cepat pewaktu Detik Jam Menit @@ -58,7 +57,6 @@ Pilih waktu Jeda Sebentar Nama Alarm Satu Waktu - Mengulangi Hari kerja Akhir pekan Keluar @@ -92,7 +90,6 @@ Mulai Tampilkan Tanggal Simpan - Tambahkan pewaktu prasetel Widget Jam Digital Hapus Jam Dunia Cari Negara/Zona Waktu diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index c897ecb29..dd9aa7c4b 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -53,11 +53,9 @@ V S Differenza - Mostra selezione rapida dei timer Seleziona durata di Posponi Nome della sveglia Una sola volta - Ripetuta Fine settimana Giorni della settimana Servizio del timer @@ -78,12 +76,10 @@ Ripeti Scheda iniziale Elimina sveglia(e) - 5 min Riavvia Attiva/disattiva elementi Orologio La sveglia suonerà il %1$s. - Usa pulsante di avvio grande Esporta sveglie Esegui un backup delle sveglie in un file json Prossima sveglia @@ -96,7 +92,6 @@ Disabilita ottimizzazione batteria Questa autorizzazione è necessaria affinché i timer funzionino quando lo schermo del telefono è spento. Dimensione testo della data - Aggiungi timer predefinito Inizio Elimina orologio mondiale diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index 1a337f3fd..66d905ec4 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -45,7 +45,6 @@ מחיקה אלפביתי היסט - הצגת בחירה מהירה של מתזמן שעות דקות שניות @@ -76,10 +75,8 @@ זמן לסבב הצגת תאריך גודל כתב תאריך - הוספת שעון עצר מוגדר ‌מ־ עד - חוזר גודל כתב שעה שמירה הצגת השעה @@ -125,11 +122,9 @@ צבע כתב תאריך צבע כתב שעה מחיקת התראה/ות - 5 דק׳ הפעלה מחדש החלפת מצב פריטים שעון - להשתמש בכפתור התחלה גדול ייצוא התראות גיבוי התראות לקובץ json התראה בקרוב diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index adf92c3e7..edea203b8 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -50,7 +50,6 @@ デジタル時計 縦時計 削除 - タイマーのクイック選択を表示 時間 @@ -67,7 +66,6 @@ スヌーズの時間を選択 バイブレーションのパターンを選択 バイブレーションのパターン: %s - 繰り返す diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml index 9c5f6fe00..85d903b72 100644 --- a/app/src/main/res/values-nb-rNO/strings.xml +++ b/app/src/main/res/values-nb-rNO/strings.xml @@ -32,7 +32,6 @@ Drakt Mørk Lys - Vis hurtigvalg av tidsur Timer Egendefinert fil Tidsurtjeneste diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 1ca06b91a..31da9e582 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -1,6 +1,5 @@ - Toon timer snelselectie Uren Bewerk wekker Aangepast bestand diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index dffafa606..9a1223936 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -42,7 +42,6 @@ Zakończono odliczanie Usuń Czas trwania: %1$s - Pokaż opcje szybkiego wyboru Godziny Usługa timera Alfabetycznie @@ -53,7 +52,6 @@ Wg przesunięcia czasu Powtórz Jednorazowy - Cykliczny Weekendy W tygodniu AMOLED @@ -101,7 +99,6 @@ Czas okrążenia Całkowity czas Rozmiar tekstu daty - Dodaj wstępnie ustawiony minutnik Start Zezwalaj na powiadomienia, aby mieć pewność, że nigdy nie przegapisz alarmu ani minutnika. Będziemy wysyłać alerty tylko dla ustawionych alarmów i minutników. Pozwolenie na powiadomienia @@ -129,7 +126,6 @@ Wybierz wzór wibracji Widżet zegara cyfrowego Usuń alarm(y) - 5 minut Uruchom ponownie Wybrane zakładki Zegar @@ -143,13 +139,8 @@ Migracja Importuj alarmy Importuj alarmy z Fossify - Użyj dużego przycisku start Nadchodzący alarm Włączy się o %1$s - Minutnik %1$s jest uruchomiony - Minutnik jest uruchomiony - Minutnik (%1$s) jest wstrzymany - Minutnik jest wstrzymany Minutnik %1$s zakończył odliczanie Stoper uruchomiony Stoper wstrzymany diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index ae27f444b..0b64a2871 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -46,7 +46,6 @@ Configurações Sobre Desativar - Mostrar seleção rápida do cronômetro Horas Soneca Segundos @@ -54,7 +53,6 @@ Selecionar tempo da soneca Nome do alarme Uma vez - Repetindo Fins de semana Dias da semana O alarme irá começar em %1$s. @@ -97,7 +95,6 @@ Tamanho do Texto da Data Iniciar Fuso horário - Adicionar temporizador pré-definido Utilizar um fuso horário diferente para o widget Mostrar fundo do widget Exibir hora diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 309d72fb8..047d466c0 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -45,7 +45,6 @@ Alfabético Desvio Eliminar - Mostrar seleção rápida do temporizador Horas Minutos Segundos @@ -58,7 +57,6 @@ Selecionar tempo da soneca Nome do alarme Uma vez - Repetindo Dias da semana %d hora a partir de agora diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 7e113ba9d..6b189098e 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -45,13 +45,11 @@ Porniți temporizatorul\? Durată: %1$s Șterge - Afișați selecția rapidă a temporizatorului Ore Minute Secunde Denumirea alarmei O dată - Repetare Weekend-uri Snooze Respinge diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 1bb7f68da..030fa8ed9 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -45,7 +45,6 @@ Длительность: %1$s Удалить Запустить секундомер\? - Показать быстрый выбор таймера Часы Минуты Секунды @@ -81,7 +80,6 @@ Единожды Будние дни Выберите время повтора - Повторяющийся Имя будильника Catppuccin Чёрная @@ -103,7 +101,6 @@ Показывать дату Сохранить Размер шрифта даты - Добавить предустановку Начать Удалить мировое время Поиск по стране/часовому поясу @@ -129,11 +126,9 @@ Цвет текста даты Цвет текста времени Удалить будильник(-и) - 5 мин Перезапустить Выбрать элементы Часы - Использовать большую кнопку \"старт\" Экспортировать будильники Сохранить будильники в json файл Ближайший будильник diff --git a/app/src/main/res/values-ryu/strings.xml b/app/src/main/res/values-ryu/strings.xml index 0449ab57b..892b3e5ca 100644 --- a/app/src/main/res/values-ryu/strings.xml +++ b/app/src/main/res/values-ryu/strings.xml @@ -50,7 +50,6 @@ デジタルどぅけいうぅい しーさるちょくとぅけい さくじょ - タイマーぬクイックしんたくひょうじ じがん ふん びょう diff --git a/app/src/main/res/values-sat/strings.xml b/app/src/main/res/values-sat/strings.xml index 18156e1bd..a857ef9a4 100644 --- a/app/src/main/res/values-sat/strings.xml +++ b/app/src/main/res/values-sat/strings.xml @@ -7,7 +7,6 @@ ᱛᱷᱤᱢ ᱥᱤᱥᱴᱚᱢ ᱢᱮᱴᱟᱣ - ᱴᱟᱭᱢᱚᱨ ᱞᱚᱜᱚᱱ ᱵᱟᱪᱷᱟᱣ ᱫᱮᱠᱷᱟᱣ ᱢᱮ ᱴᱟᱲᱟᱝ ᱜᱷᱚᱰᱤ ᱛᱟᱹᱝᱜᱤᱜᱷᱚᱰᱤ diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index f8cde9eaa..ba346f036 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -48,7 +48,6 @@ Тамна Изворни кôд Погледајте изворни кôд апликације - Прикажи брзи избор тајмера Сати Секунде Минути @@ -80,7 +79,6 @@ Једном Радним данима Изаберите време одлагања - Понављање Назив аларма Catppuccin Amoled @@ -103,7 +101,6 @@ Сачувај Почетак Величина текста датума - Додај унапред подешени тајмер Претрага земље/временске зоне Избриши светски сат Прикажи позадину виџета diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml index 8eb5010a1..11fd7b86d 100644 --- a/app/src/main/res/values-ta/strings.xml +++ b/app/src/main/res/values-ta/strings.xml @@ -22,7 +22,6 @@ தேதியைக் காட்டு சேமி தேதி உரை அளவு - முன்னமைக்கப்பட்ட டைமரைச் சேர்க்கவும் தொடங்கு உலக கடிகாரத்தை நீக்கு நாடு/நேர மண்டலத்தைத் தேடுங்கள் @@ -73,7 +72,6 @@ டிசிட்டல் கடிகாரம் செங்குத்து கடிகாரம் அழி - நேரங்குறிகருவி விரைவான தேர்வைக் காட்டு மணி நிமிடங்கள் @@ -87,7 +85,6 @@ உறக்கநிலை நேரத்தைத் தேர்ந்தெடுக்கவும் அலாரம் பெயர் ஒரு முறை - மீண்டும் வார இறுதி நாட்கள் வார நாட்கள் அலாரம் %1$s இல் தொடங்கும். diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 782d6397d..fe28172c5 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -57,7 +57,6 @@ Erteleme süresini seç Alarm Adı Bir Kere - Tekrarla Hafta sonları Hafta içi günleri Ertele @@ -67,7 +66,6 @@ Dakika - Zamanlayıcı hızlı seçimini göster Saniye Başlangıç Bitiş @@ -99,7 +97,6 @@ Başlat Tarihi Göster Kaydet - Ön ayarlı zamanlayıcı ekle Dünya Saatini Sil Ülke/Saat Dilimi Ara Widget için farklı bir saat dilimi kullan @@ -124,11 +121,9 @@ Tarih metni rengi Zaman metni rengi Alarm(lar)ı sil - 5 Dak Yeniden başlat Ögeleri değiştir Saat - Büyük başlat düğmesi kullan Alarmları Dışa Aktar alarmları json dosyasına yedekle Yaklaşan Alarm diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 7f047bf57..e1fdb6308 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -45,7 +45,6 @@ Видалити Зміщення За алфавітом - Показати швидкий вибір таймера Години Хвилини Секунди @@ -81,7 +80,6 @@ Один раз Робочі дні Виберіть час повтору - Повторення Назва будильника Catppuccin Чорна @@ -105,7 +103,6 @@ Показувати дату Зберегти Розмір тексту дати - Додати передустановку таймера Розпочати Видалити світовий годинник Шукати країну/часовий пояс @@ -129,7 +126,6 @@ Колір тексту часу Колір тексту дати Видалити будильник(-и) - 5 хв Перезапустити Перемкнути елементи Годинник @@ -137,7 +133,6 @@ Деякі виробники використовують власні дозволи на доступ у фоновому режимі Вимкнути оптимізацію акумулятора Цей дозвіл потрібен для того, щоби таймери працювали, коли екран телефону вимкнено. - Використовувати велику кнопку запуску Експортувати будильники Зберегти копію будильників у json-файл Найближчий будильник diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 9e8321b4b..e19367dbc 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -46,7 +46,6 @@ Kích thước văn bản thời gian Tiện ích đồng hồ kỹ thuật số Một lần - Lặp lại Tên báo thức Những ngày cuối tuần Các ngày trong tuần @@ -57,7 +56,6 @@ Mã nguồn Xem mã nguồn của ứng dụng Ngủ nướng - Hiển thị lựa chọn nhanh bộ hẹn giờ Giờ Giây Vòng thời gian @@ -66,7 +64,6 @@ Hiển thị ngày Lưu Kích thước văn bản ngày - Thêm bộ hẹn giờ đặt trước Bắt đầu Từ Tới diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index c6de5cf61..e8f2654d9 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -45,7 +45,6 @@ 删除 按字母顺序 偏移 - 显示快速选择 小时 分钟 @@ -72,7 +71,6 @@ 工作日 Catppuccin 纯黑 - 重复 配色方案 闹钟将在 %1$s 后启动。 开始标签页 @@ -90,7 +88,6 @@ 显示日期 保存 日期文本大小 - 添加预设定时器 总时间 启动 数字时钟微件 @@ -116,7 +113,6 @@ 减少 %d 分钟 日期文本颜色 时间文本颜色 - 5 分钟 重启 切换项目 时钟 @@ -131,13 +127,8 @@ 迁移 导入闹钟 从 Fossify 导入闹钟 - 使用大启动按钮 预定的闹钟 将在 %1$s 响起 - %1$s 定时器正在运行 - 定时器正在运行 - %1$s 定时器已暂停 - 定时器已暂停 %1$s 定时器已结束 秒表正在运行 秒表已暂停 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index e80a0d3c1..1c601f324 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -1,7 +1,6 @@ 關閉 - 顯示計時器快速選擇 小時 編輯鬧鐘 貪睡 @@ -62,11 +61,9 @@ 數位樣式時鐘小組件 選擇貪睡時間 - 重複 單圈時間 總時間 - 新增現在的計時器 顯示日期 儲存 日期文字大小 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 408df9f15..dc9d30360 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -9,11 +9,8 @@ Settings About Timer service - %1$s timer is running - Timer is running - %1$s timer is paused - Timer is paused %1$s timer finished + %1$s timer finished for %2$s Stopwatch is running Stopwatch is paused @@ -24,8 +21,10 @@ Vibrate Delete alarm Delete alarm(s) + Delete timer(s) Are you sure? This can\'t be undone! Nothing here. + %1$d selected Timer finished Stop Pause @@ -45,8 +44,10 @@ Default Start timer? Duration: %1$s - 5 Min + Add 1 min Restart + Reset + %1$s paused Alphabetic Offset @@ -64,7 +65,6 @@ Toggle items Clock - Use big start button Export alarms Backup alarms to a JSON file Upcoming alarm @@ -92,22 +92,36 @@ Digital clock Vertical clock Picker style + Picker with a timer running + Keep open + Hide + Full-screen timer alert + Use big start button + Increment amount + Select increment amount + Default + Show picker + Hide picker Picker style Scroll Numpad Delete Timeout after Select alarm timeout + Select timer timeout + Gradually increase volume + Select volume ramp + Never Missed alarms %1$s alarm at %2$s was missed Alarm at %1$s was missed - Show quick selection Hours Minutes Seconds Snooze Dismiss Volume buttons + Volume buttons Volume Nothing Same time @@ -129,7 +143,7 @@ Select snooze length Alarm name One time - Repeating + Every day Weekends Weekdays The alarm will start in %1$s. @@ -138,6 +152,7 @@ Never rings Repeats until %1$s Repeats again on %1$s + For %1$d %2$s every %3$d %4$s less than 1 minute AMOLED Catppuccin @@ -167,6 +182,14 @@ %d hour and %d minutes behind %d hours and %d minutes behind + + %d second + %d seconds + + + Add %d sec + Add %d secs + %d minute %d minutes @@ -203,6 +226,14 @@ year years + + Active timer + Active timers + + + Saved timer + Saved timers + Every day Every %d days @@ -243,7 +274,7 @@ Show date Save Date text size - Add preset timer + Add saved timer Start Delete world clock Search country or time zone diff --git a/app/src/test/java/com/bnyro/clock/data/database/AppDatabaseMigrationTest.kt b/app/src/test/java/com/bnyro/clock/data/database/AppDatabaseMigrationTest.kt new file mode 100644 index 000000000..e61ba2494 --- /dev/null +++ b/app/src/test/java/com/bnyro/clock/data/database/AppDatabaseMigrationTest.kt @@ -0,0 +1,74 @@ +package com.bnyro.clock.data.database + +import android.content.Context +import androidx.room.Room +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.SupportSQLiteOpenHelper +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class AppDatabaseMigrationTest { + @Test + fun migrate12To13MovesTheOldDefaultVibrationOntoTheNewOne() { + val context = ApplicationProvider.getApplicationContext() + + val v12Helper = FrameworkSQLiteOpenHelperFactory().create( + SupportSQLiteOpenHelper.Configuration.builder(context) + .name(TEST_DB) + .callback( + object : SupportSQLiteOpenHelper.Callback(12) { + override fun onCreate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE TABLE IF NOT EXISTS `timeZones` (`key` TEXT NOT NULL, `zoneId` TEXT NOT NULL, `zoneName` TEXT NOT NULL, `countryName` TEXT NOT NULL, PRIMARY KEY(`key`))" + ) + db.execSQL( + "CREATE TABLE IF NOT EXISTS `alarms` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `time` INTEGER NOT NULL, `label` TEXT, `enabled` INTEGER NOT NULL, `days` TEXT NOT NULL, `vibrate` INTEGER NOT NULL, `soundName` TEXT, `soundUri` TEXT, `snoozeEnabled` INTEGER NOT NULL DEFAULT 1, `snoozeMinutes` INTEGER NOT NULL DEFAULT 10, `soundEnabled` INTEGER NOT NULL DEFAULT 1, `vibrationPattern` TEXT NOT NULL DEFAULT '1000,1000,1000,1000,1000', `vibrationPatternName` TEXT NOT NULL DEFAULT 'Default', `dismissedAt` INTEGER DEFAULT NULL, `startDate` INTEGER NOT NULL DEFAULT 0, `repeatInterval` INTEGER NOT NULL DEFAULT 1, `repeatUnit` TEXT NOT NULL DEFAULT 'WEEK', `repeatAnchor` TEXT NOT NULL DEFAULT 'DAY_OF_MONTH', `repeatDuration` INTEGER DEFAULT NULL, `repeatDurationUnit` TEXT NOT NULL DEFAULT 'DAY', `endDate` INTEGER DEFAULT NULL, `endOccurrences` INTEGER DEFAULT NULL, `advanced` INTEGER NOT NULL DEFAULT 0)" + ) + db.execSQL( + "INSERT INTO alarms (id, time, enabled, days, vibrate, vibrationPattern, vibrationPatternName) VALUES " + + "(1, 0, 1, '0', 1, '1000,1000,1000,1000,1000', 'Default'), " + + "(2, 0, 1, '0', 1, '1000,1000,1000,1000,1000', 'Heartbeat'), " + + "(3, 0, 1, '0', 1, '500,500,500', 'Custom')" + ) + } + + override fun onUpgrade( + db: SupportSQLiteDatabase, + oldVersion: Int, + newVersion: Int + ) = Unit + } + ) + .build() + ) + v12Helper.writableDatabase + v12Helper.close() + + val db = Room.databaseBuilder(context, AppDatabase::class.java, TEST_DB) + .addMigrations(AppDatabase.MIGRATION_12_13) + .build() + val alarms = runBlocking { db.alarmsDao().getAll() } + db.close() + + assertEquals( + listOf( + listOf(0, 1000, 1000, 1000, 1000), + listOf(1000, 1000, 1000, 1000, 1000), + listOf(500, 500, 500) + ), + alarms.sortedBy { it.id }.map { it.vibrationPattern } + ) + } + + companion object { + private const val TEST_DB = "migration-test" + } +}