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"
+ }
+}