From 556e83a7709c03a9ab6edf08fdada5c392889b70 Mon Sep 17 00:00:00 2001 From: Ris Peng Date: Thu, 27 Aug 2026 23:28:07 +0800 Subject: [PATCH 01/44] move alarm recurrence onto the name row Keep the name row present and hide its label icon when the alarm has no name. Describe seven selected weekdays as every day. --- .../screens/alarm/components/AlarmCard.kt | 127 +++++++++--------- app/src/main/res/values-ar/strings.xml | 1 - app/src/main/res/values-az/strings.xml | 1 - app/src/main/res/values-be/strings.xml | 1 - app/src/main/res/values-bg/strings.xml | 1 - app/src/main/res/values-bo/strings.xml | 1 - app/src/main/res/values-ca/strings.xml | 1 - app/src/main/res/values-cs/strings.xml | 1 - app/src/main/res/values-da/strings.xml | 1 - app/src/main/res/values-de/strings.xml | 1 - app/src/main/res/values-el/strings.xml | 1 - app/src/main/res/values-es/strings.xml | 1 - app/src/main/res/values-et/strings.xml | 1 - app/src/main/res/values-fa/strings.xml | 1 - app/src/main/res/values-fi/strings.xml | 1 - app/src/main/res/values-fr/strings.xml | 1 - app/src/main/res/values-hi/strings.xml | 1 - app/src/main/res/values-ia/strings.xml | 1 - app/src/main/res/values-in/strings.xml | 1 - app/src/main/res/values-it/strings.xml | 1 - app/src/main/res/values-iw/strings.xml | 1 - app/src/main/res/values-ja/strings.xml | 1 - app/src/main/res/values-nb-rNO/strings.xml | 1 - app/src/main/res/values-nl/strings.xml | 1 - app/src/main/res/values-pl/strings.xml | 1 - app/src/main/res/values-pt-rBR/strings.xml | 1 - app/src/main/res/values-pt/strings.xml | 1 - app/src/main/res/values-ro/strings.xml | 1 - app/src/main/res/values-ru/strings.xml | 1 - app/src/main/res/values-ryu/strings.xml | 1 - app/src/main/res/values-sat/strings.xml | 1 - app/src/main/res/values-sr/strings.xml | 1 - app/src/main/res/values-ta/strings.xml | 1 - app/src/main/res/values-tr/strings.xml | 1 - app/src/main/res/values-uk/strings.xml | 1 - app/src/main/res/values-vi/strings.xml | 1 - app/src/main/res/values-zh-rCN/strings.xml | 1 - app/src/main/res/values-zh-rTW/strings.xml | 1 - app/src/main/res/values/strings.xml | 2 +- 39 files changed, 63 insertions(+), 103 deletions(-) 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 2bc21311b..3626656b8 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,72 @@ 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(start = 5.dp, 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)) + } + 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.every_day)) + } + + alarm.isWeekends -> { + Text(text = stringResource(R.string.weekends)) + } + + alarm.isWeekdays -> { + Text(text = stringResource(R.string.weekdays)) + } + + else -> { + val daysOfWeek = remember { + AlarmHelper.getDaysOfWeekByLocale(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 +154,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.getDaysOfWeekByLocale(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/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index a5d8b6bbf..8b7c5b000 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -94,7 +94,6 @@ مرة واحدة أيام الأسبوع حدد مدة الغفوة - تكرار اسم التنبيه كاتبوتشين ‬أموليد diff --git a/app/src/main/res/values-az/strings.xml b/app/src/main/res/values-az/strings.xml index e92b9d9a5..5346e9da6 100644 --- a/app/src/main/res/values-az/strings.xml +++ b/app/src/main/res/values-az/strings.xml @@ -60,7 +60,6 @@ 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 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..f1903939f 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -73,7 +73,6 @@ Еднократно Работни дни Период на отлагане - Повтарящо се Име на будилника Catppuccin Черна diff --git a/app/src/main/res/values-bo/strings.xml b/app/src/main/res/values-bo/strings.xml index e1f3cf35f..ee8caf8cc 100644 --- a/app/src/main/res/values-bo/strings.xml +++ b/app/src/main/res/values-bo/strings.xml @@ -58,7 +58,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..a67f1229f 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -60,7 +60,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..98981c2d0 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -77,7 +77,6 @@ Jednou Pracovní dny Vyberte čas odložení - Opakovaně Název budíku Catppuccin Amoled diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 3e0d02b43..9f556e600 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -32,7 +32,6 @@ Alarmnavn Weekender Hverdage - Gentagende Start-fane Alarmen starter om %1$s. Amoled diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 3fdf447ca..2795b3938 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -79,7 +79,6 @@ Snooze-Zeit auswählen Name des Alarms Einmal - Wiederholend Wochenende Wochentage S diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 446e07cb0..c341d0743 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -58,7 +58,6 @@ Ίδια ώρα Επανάληψη Όνομα ξυπνητηριού - Επαναλαμβανόμενη Σαββατοκύριακα Το ξυπνητήρι θα ξεκινήσει σε %1$s. Χρωματικό σχέδιο diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 7de654cf3..0fc55213c 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -78,7 +78,6 @@ Una vez Días laborables Seleccione el tiempo para la repetición - Repitiendo Nombre de la alarma Catppuccin Amoled diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 9d4148365..462472e4d 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -54,7 +54,6 @@ Katkesta Äratuse nimi Üks kord - Korduv Nädalavahetused Tööpäevad Äratuse aeg on %1$s. diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 4db7b50bc..6ece1291b 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -50,7 +50,6 @@ ساعت راست‌ایستا پاک‌کردن ساعت‌ها - تکرار درنگ ادامه سرنام diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 724651515..05a25172d 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -61,7 +61,6 @@ Kerran Valitse torkutusaika - Toistuva Herätyksen nimi Viikonloput Arkipäivät diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 9fe5af0a1..44ce39657 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -77,7 +77,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 diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 02da58a86..a66383e8c 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -56,7 +56,6 @@ प्रारंभ टैब से तक - दोहराना %d घंटा और %d मिनट आगे %d घंटे और %d मिनट आगे diff --git a/app/src/main/res/values-ia/strings.xml b/app/src/main/res/values-ia/strings.xml index b149782ec..03db2bc2c 100644 --- a/app/src/main/res/values-ia/strings.xml +++ b/app/src/main/res/values-ia/strings.xml @@ -54,7 +54,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..5db021832 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -58,7 +58,6 @@ Pilih waktu Jeda Sebentar Nama Alarm Satu Waktu - Mengulangi Hari kerja Akhir pekan Keluar diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index c897ecb29..ad24be572 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -57,7 +57,6 @@ Seleziona durata di Posponi Nome della sveglia Una sola volta - Ripetuta Fine settimana Giorni della settimana Servizio del timer diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index 1a337f3fd..cc73b9e4f 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -79,7 +79,6 @@ הוספת שעון עצר מוגדר ‌מ־ עד - חוזר גודל כתב שעה שמירה הצגת השעה diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index adf92c3e7..45e236895 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -67,7 +67,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..3b86b606a 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -53,7 +53,6 @@ Wg przesunięcia czasu Powtórz Jednorazowy - Cykliczny Weekendy W tygodniu AMOLED diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index ae27f444b..0d4655a2a 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -54,7 +54,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. diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 309d72fb8..cf8c71a3f 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -58,7 +58,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..19d4125dd 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -51,7 +51,6 @@ 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..b90c9b9a3 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -81,7 +81,6 @@ Единожды Будние дни Выберите время повтора - Повторяющийся Имя будильника Catppuccin Чёрная 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..8f4fc4471 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -80,7 +80,6 @@ Једном Радним данима Изаберите време одлагања - Понављање Назив аларма Catppuccin Amoled diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml index 8eb5010a1..550e0be3d 100644 --- a/app/src/main/res/values-ta/strings.xml +++ b/app/src/main/res/values-ta/strings.xml @@ -87,7 +87,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..ca5e3d298 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 diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 7f047bf57..80581ef9a 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -81,7 +81,6 @@ Один раз Робочі дні Виберіть час повтору - Повторення Назва будильника Catppuccin Чорна diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 9e8321b4b..c41927ada 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 diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index c6de5cf61..cdd160dc8 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -72,7 +72,6 @@ 工作日 Catppuccin 纯黑 - 重复 配色方案 闹钟将在 %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..08a1f4cf5 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -62,7 +62,6 @@ 數位樣式時鐘小組件 選擇貪睡時間 - 重複 單圈時間 總時間 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index af236f2b7..4a02b1668 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -129,7 +129,7 @@ Select snooze length Alarm name One time - Repeating + Every day Weekends Weekdays The alarm will start in %1$s. From aa0534c441ade4cfc5a43eb2f86ac550821c5fce Mon Sep 17 00:00:00 2001 From: Ris Peng Date: Thu, 27 Aug 2026 23:28:30 +0800 Subject: [PATCH 02/44] combine timer creation and management on one screen The timer screen switched between picker and list layouts depending on active timers. Show the picker, start button, active timers, and saved timers together, with duration-based default names and vibration enabled for new timers. Open editing from the entire card and use a sheet containing the selected picker, label, sound, and vibration controls. Editing a running timer's duration changes its restart duration without changing the current countdown. Store saved timer labels, sounds, and vibration patterns as JSON under a new preference key and pass them to the service on start. Preserve paused state on reset and restart finished timers. Reuse the alarm number keypad and let each shared wheel consume its own drags. Remove the quick selection and large start button settings because saved timers are always visible and the screen uses the stopwatch-style start button. --- .../clock/domain/model/PersistentTimer.kt | 47 -- .../clock/domain/model/TimerDescriptor.kt | 15 +- .../bnyro/clock/domain/model/TimerObject.kt | 27 +- .../bnyro/clock/domain/model/TimerSettings.kt | 48 ++ .../com/bnyro/clock/navigation/NavHost.kt | 2 +- .../components/ScrollTimerPicker.kt | 166 +++--- .../presentation/components/ScrollWheel.kt | 20 + .../features/TimerReceiverDialog.kt | 3 +- .../alarm/components/AlarmNumberKeypad.kt | 143 ----- .../alarm/components/AlarmTimePicker.kt | 4 +- .../alarm/components/ScrollAlarmTimePicker.kt | 24 +- .../screens/settings/SettingsScreen.kt | 18 +- .../settings/components/SettingsCategory.kt | 10 +- .../presentation/screens/timer/TimerScreen.kt | 537 ++++-------------- .../timer/components/FormattedTimerTime.kt | 8 +- .../screens/timer/components/NumberKeypad.kt | 118 ++-- .../timer/components/SavedTimerItem.kt | 76 +++ .../timer/components/TimerEditSheet.kt | 215 +++++++ .../screens/timer/components/TimerItem.kt | 96 +--- .../timer/components/TimerPickerSelector.kt | 115 ++++ .../screens/timer/model/TimerModel.kt | 99 +--- .../java/com/bnyro/clock/ui/MainActivity.kt | 8 +- .../java/com/bnyro/clock/util/Preferences.kt | 3 +- .../java/com/bnyro/clock/util/TimeHelper.kt | 12 + .../bnyro/clock/util/services/TimerService.kt | 83 ++- app/src/main/res/values-ar/strings.xml | 5 - app/src/main/res/values-az/strings.xml | 2 - app/src/main/res/values-bg/strings.xml | 2 - app/src/main/res/values-ca/strings.xml | 1 - app/src/main/res/values-cs/strings.xml | 3 - app/src/main/res/values-da/strings.xml | 2 - app/src/main/res/values-de/strings.xml | 3 - app/src/main/res/values-el/strings.xml | 2 - app/src/main/res/values-es/strings.xml | 2 - app/src/main/res/values-et/strings.xml | 3 - app/src/main/res/values-fa/strings.xml | 2 - app/src/main/res/values-fi/strings.xml | 2 - app/src/main/res/values-fr/strings.xml | 3 - app/src/main/res/values-hi/strings.xml | 2 - app/src/main/res/values-ia/strings.xml | 1 - app/src/main/res/values-in/strings.xml | 2 - app/src/main/res/values-it/strings.xml | 3 - app/src/main/res/values-iw/strings.xml | 3 - app/src/main/res/values-ja/strings.xml | 1 - app/src/main/res/values-pl/strings.xml | 5 - app/src/main/res/values-pt-rBR/strings.xml | 2 - app/src/main/res/values-pt/strings.xml | 1 - app/src/main/res/values-ro/strings.xml | 1 - app/src/main/res/values-ru/strings.xml | 3 - app/src/main/res/values-sr/strings.xml | 2 - app/src/main/res/values-ta/strings.xml | 2 - app/src/main/res/values-tr/strings.xml | 3 - app/src/main/res/values-uk/strings.xml | 3 - app/src/main/res/values-vi/strings.xml | 2 - app/src/main/res/values-zh-rCN/strings.xml | 5 - app/src/main/res/values-zh-rTW/strings.xml | 2 - app/src/main/res/values/strings.xml | 14 +- 57 files changed, 889 insertions(+), 1097 deletions(-) delete mode 100644 app/src/main/java/com/bnyro/clock/domain/model/PersistentTimer.kt create mode 100644 app/src/main/java/com/bnyro/clock/domain/model/TimerSettings.kt delete mode 100644 app/src/main/java/com/bnyro/clock/presentation/screens/alarm/components/AlarmNumberKeypad.kt create mode 100644 app/src/main/java/com/bnyro/clock/presentation/screens/timer/components/SavedTimerItem.kt create mode 100644 app/src/main/java/com/bnyro/clock/presentation/screens/timer/components/TimerEditSheet.kt create mode 100644 app/src/main/java/com/bnyro/clock/presentation/screens/timer/components/TimerPickerSelector.kt 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..4b324e5d6 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,20 @@ 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 ) } -} \ 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..c8ed522c2 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,30 @@ package com.bnyro.clock.domain.model -import android.net.Uri import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf 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 = List(5) { 1000 }, + var vibrationPatternName: String = "Default" +) { + val settings: TimerSettings + get() = TimerSettings( + seconds = initialPosition.value / 1000, + label = label.value, + soundName = soundName, + soundUri = soundUri, + soundEnabled = soundEnabled, + vibrate = vibrate, + vibrationPattern = vibrationPattern, + vibrationPatternName = vibrationPatternName + ) +} 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..dd724dd37 --- /dev/null +++ b/app/src/main/java/com/bnyro/clock/domain/model/TimerSettings.kt @@ -0,0 +1,48 @@ +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.json.Json + +/** + * Everything a timer is started with, and everything a saved timer keeps between runs. + * + * @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( + 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 = List(5) { 1000 }, + val vibrationPatternName: String = "Default" +) : Parcelable { + companion object { + private val exampleTimers = listOf( + 60 * 10, + 60 * 15, + 60 * 30, + 60 * 60 + ).map { TimerSettings(it) } + + 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) + } + } +} 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 011ce9e3b..150c113ad 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 @@ -19,10 +19,15 @@ 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 @@ -45,6 +50,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() } @@ -81,6 +100,7 @@ fun ScrollWheel( } VerticalPager( modifier = Modifier + .nestedScroll(keepDragsOnTheWheel) .height(224.dp) .widthIn(min = if (widestLabel >= 3) 96.dp else 0.dp), state = state, 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..c37d0424a 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(duration)) showDialog = 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/settings/SettingsScreen.kt b/app/src/main/java/com/bnyro/clock/presentation/screens/settings/SettingsScreen.kt index 8970507b5..73f4898f7 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 @@ -52,7 +52,6 @@ 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 @@ -61,8 +60,7 @@ import com.bnyro.clock.util.services.AlarmService fun SettingsScreen( onClickBack: () -> Unit, onNavigate: (String) -> Unit, - settingsModel: SettingsModel, - timerModel: TimerModel + settingsModel: SettingsModel ) { val context = LocalContext.current val scrollState = rememberScrollState() @@ -289,22 +287,8 @@ 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 - ) - - SwitchPref( - prefKey = "timer_BIG_start_button", - title = stringResource(R.string.timer_use_big_start), - defaultValue = false - ) - HorizontalDivider( modifier = Modifier.padding(top = 12.dp, bottom = 8.dp), color = MaterialTheme.colorScheme.surfaceVariant 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/timer/TimerScreen.kt b/app/src/main/java/com/bnyro/clock/presentation/screens/timer/TimerScreen.kt index 4de7d24ec..a2667af28 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,168 @@ 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.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.itemsIndexed +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.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.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.collectAsState 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.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.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.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.util.extensions.KeepScreenOn -@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) + val activeTimers by timerModel.scheduledObjects.collectAsState() - var createNew by remember { - mutableStateOf(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) - } + var editedTimer by remember { mutableStateOf(null) } + var editedSavedTimerIndex by remember { mutableStateOf(null) } TopBarScaffold( - title = screenTitle, - onClickSettings = onClickSettings, - fabPosition = settingsModel.fabAlignment.position, - actions = { - if (scheduledObjects.isEmpty() && showExampleTimers && selectedPresets.isEmpty()) { - ClickableIcon( - imageVector = Icons.Rounded.AddAlarm, - contentDescription = stringResource(R.string.add_preset_timer) - ) { - timerModel.addPersistentTimer(timerModel.timePickerSeconds) - } - } - }, - fab = { - if (scheduledObjects.isNotEmpty() && selectedPresets.isEmpty()) { - FloatingActionButton(onClick = { - createNew = true - }) { - Icon(Icons.Rounded.Add, contentDescription = null) - } - } - }) { paddingValues -> - if (scheduledObjects.isEmpty()) { - Column( - Modifier.padding(paddingValues) - ) { - TimerPicker( + title = stringResource(R.string.timer), + onClickSettings = onClickSettings + ) { paddingValues -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentPadding = PaddingValues(bottom = 16.dp) + ) { + item { + TimerPickerSelector( pickerStyle = settingsModel.timerPickerStyle, - timerModel = timerModel, - showExampleTimers = showExampleTimers, - context = context, - onCreateNew = { createNew = false }, - showFAB = false, - useSimpleStartButton = usebigassStartButton, - selectedPresets = selectedPresets, - onSelectedPresetsChanged = { selectedPresets = it } + seconds = timerModel.timePickerSeconds, + onSecondsChanged = { timerModel.timePickerSeconds = it } ) - } - } else { - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues), - verticalArrangement = Arrangement.Top - ) { - items(scheduledObjects, key = { it.id }) { obj -> - TimerItem(obj, timerModel) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + horizontalArrangement = Arrangement.Center + ) { + LargeFloatingActionButton( + shape = CircleShape, + onClick = { + timerModel.startTimer( + context, + TimerSettings(timerModel.timePickerSeconds) + ) + } + ) { + Icon(Icons.Default.PlayArrow, contentDescription = null) + } } } - 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 - ) { - Box( - Modifier - .fillMaxHeight() - .weight(1f) - ) { - TimerPickerSelector(pickerStyle, timerModel) - } - 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 (selectedPresets.isNotEmpty()) { - SelectionActionButtons( - selectedPresets = selectedPresets, - timerModel = timerModel, - usebigassButtons = useSimpleStartButton, - clearSelection = { onSelectedPresetsChanged(emptySet()) } - ) - } else { - StartTimerButton(showFAB, onCreateNew, timerModel, context, useSimpleStartButton) + if (activeTimers.isNotEmpty()) { + item { + Column(modifier = Modifier.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) + ) + } } - } - } - } -} - -@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) + items(activeTimers, key = { it.id }) { timer -> + TimerItem(timer, timerModel, onEdit = { editedTimer = timer }) } - clearSelection() } - ) { - Text( - text = stringResource(R.string.delete), - style = textStyle, - textAlign = TextAlign.Center - ) - } - } -} - -@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 + item { + Column(modifier = Modifier.padding(horizontal = 16.dp)) { + HorizontalDivider( + modifier = Modifier.padding(top = 12.dp, bottom = 8.dp), + color = MaterialTheme.colorScheme.surfaceVariant ) - .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() - if (isSelected) { - newSelection.remove(index) - } else { - newSelection.add(index) - } - onSelectedPresetsChanged(newSelection) + SettingsCategory( + pluralStringResource(R.plurals.saved_timers, timerModel.savedTimers.size) + ) { + ClickableIcon( + imageVector = Icons.Rounded.AddAlarm, + contentDescription = stringResource(R.string.add_saved_timer) + ) { + timerModel.addSavedTimer(timerModel.timePickerSeconds) } - ) - .background(MaterialTheme.colorScheme.secondaryContainer) - .padding(8.dp), - contentAlignment = Alignment.Center - ) { - Text( - timer.formattedTime, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSecondaryContainer + } + } + } + itemsIndexed(timerModel.savedTimers) { index, timer -> + SavedTimerItem( + timer = timer, + onStart = { timerModel.startTimer(context, timer) }, + onEdit = { editedSavedTimerIndex = index } ) } } } -} -@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 - } - ) - } + editedSavedTimerIndex?.let { index -> + TimerEditSheet( + currentTimer = timerModel.savedTimers[index], + pickerStyle = settingsModel.timerPickerStyle, + onSave = { settings -> + timerModel.updateSavedTimer(index, settings) + editedSavedTimerIndex = null + }, + onDelete = { + timerModel.removeSavedTimer(index) + editedSavedTimerIndex = null + }, + onDismiss = { editedSavedTimerIndex = 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..a412ee93d --- /dev/null +++ b/app/src/main/java/com/bnyro/clock/presentation/screens/timer/components/SavedTimerItem.kt @@ -0,0 +1,76 @@ +package com.bnyro.clock.presentation.screens.timer.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +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.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 + +@Composable +fun SavedTimerItem(timer: TimerSettings, onStart: () -> Unit, onEdit: () -> Unit) { + val hours = timer.seconds / 3600 + val minutes = timer.seconds % 3600 / 60 + val seconds = timer.seconds % 60 + + ElevatedCard( + modifier = Modifier + .padding(horizontal = 12.dp, vertical = 6.dp) + .clickable(onClick = onEdit), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.elevatedCardColors() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + 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..cbaa21725 --- /dev/null +++ b/app/src/main/java/com/bnyro/clock/presentation/screens/timer/components/TimerEditSheet.kt @@ -0,0 +1,215 @@ +package com.bnyro.clock.presentation.screens.timer.components + +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.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.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.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.SwitchWithDivider +import com.bnyro.clock.presentation.features.RingtonePickerDialog +import com.bnyro.clock.presentation.features.VibrationPatternPickerDialog +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 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) } + + 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(), + 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 + ) + ) + } + ) { + 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 + ) + } +} 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 7d0e40d86..b18c78938 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 @@ -15,60 +15,45 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Edit 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) { 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) } - ElevatedCard( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + modifier = Modifier + .padding(horizontal = 12.dp, vertical = 6.dp) + .clickable(onClick = onEdit), shape = RoundedCornerShape(20.dp), colors = CardDefaults.elevatedCardColors() ) { @@ -82,17 +67,14 @@ fun TimerItem(obj: TimerObject, timerModel: TimerModel) { val colorTextLowerAlpha = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f) Column(modifier = Modifier.weight(1f)) { - val titleText = obj.label.value ?: if (isFinished) stringResource(R.string.timer_finished) else null - titleText?.let { label -> - Text( - text = label, - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Normal, - color = colorTextLowerAlpha, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } + Text( + text = obj.label.value, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Normal, + color = colorTextLowerAlpha, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) Text( text = if (isFinished) "0:00:00" else "$hours:${minutes.addZero()}:${seconds.addZero()}", @@ -108,8 +90,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,10 +148,6 @@ 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.Refresh) { timerModel.restartTimer(context, obj.id) } @@ -201,56 +177,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..626335797 --- /dev/null +++ b/app/src/main/java/com/bnyro/clock/presentation/screens/timer/components/TimerPickerSelector.kt @@ -0,0 +1,115 @@ +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 +) { + 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..d50d6de18 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,47 @@ 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(0) fun onChangeTimers(objects: Array) { _timerObjects.value = listOf(*objects) } - fun removePersistentTimer(index: Int) { - persistentTimers = persistentTimers.filterIndexed { i, _ -> i != index } + fun removeSavedTimer(index: Int) { + savedTimers = savedTimers.filterIndexed { i, _ -> i != index } } - fun addPersistentTimer(seconds: Int) { + fun addSavedTimer(seconds: Int) { if (seconds == 0) return - persistentTimers = (persistentTimers + PersistentTimer(seconds)).distinct() + savedTimers = (savedTimers + TimerSettings(seconds)).distinct() + } + + fun updateSavedTimer(index: Int, settings: TimerSettings) { + savedTimers = savedTimers.mapIndexed { i, timer -> if (i == index) settings else timer } } - 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 { @@ -132,42 +111,4 @@ class TimerModel : ViewModel() { ) 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 deleteLastNumber() { - timePickerFakeUnits /= 10 - } - - fun clear() { - timePickerFakeUnits = 0 - } - /* ========================================================== */ } 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/util/Preferences.kt b/app/src/main/java/com/bnyro/clock/util/Preferences.kt index af0c1b943..191f876a6 100644 --- a/app/src/main/java/com/bnyro/clock/util/Preferences.kt +++ b/app/src/main/java/com/bnyro/clock/util/Preferences.kt @@ -13,9 +13,8 @@ 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 snoozeTimeMinutesKey = "snoozeTimeMinutes" const val alarmTimeoutMinutesKey = "alarmTimeoutMinutes" const val customColorKey = "customColor" 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..c865fa683 100644 --- a/app/src/main/java/com/bnyro/clock/util/TimeHelper.kt +++ b/app/src/main/java/com/bnyro/clock/util/TimeHelper.kt @@ -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/services/TimerService.kt b/app/src/main/java/com/bnyro/clock/util/services/TimerService.kt index 3e1908368..82ffa6f14 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 @@ -29,9 +29,11 @@ import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat 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.ui.MainActivity import com.bnyro.clock.util.NotificationHelper @@ -127,12 +129,15 @@ class TimerService : Service() { oldnow = System.currentTimeMillis() - 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) @@ -148,21 +153,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() } @@ -289,21 +295,13 @@ class TimerService : Service() { private fun getNotification(timerObject: TimerObject) = NotificationCompat.Builder( this, NotificationHelper.TIMER_CHANNEL ).setContentTitle( - timerObject.label.value?.takeIf { it.isNotBlank() }?.let { - getString( - if (timerObject.state.value == WatchState.RUNNING) { - R.string.running_named_timer - } else { - R.string.paused_named_timer - }, - it - ) - } ?: getString( + getString( if (timerObject.state.value == WatchState.RUNNING) { - R.string.running_timer + R.string.running_named_timer } else { - R.string.paused_timer - } + R.string.paused_named_timer + }, + timerObject.label.value ) ) .setContentIntent(contentIntent) @@ -464,11 +462,7 @@ class TimerService : Service() { val notification = NotificationCompat.Builder(this, notificationChannelId) .setSmallIcon(R.drawable.ic_notification) .setSilent(true) - .setContentTitle( - timerObject.label.value?.takeIf { it.isNotBlank() }?.let { - getString(R.string.finished_named_timer, it) - } ?: getString(R.string.timer_finished) - ) + .setContentTitle(getString(R.string.finished_named_timer, timerObject.label.value)) .setContentIntent(contentIntent) .setCategory(NotificationCompat.CATEGORY_ALARM) .setPriority(NotificationCompat.PRIORITY_MAX) @@ -515,25 +509,20 @@ class TimerService : Service() { R.string.add_5_minutes, ACTION_ADD_5_MIN, 6, timerObject.id ) - fun updateLabel(id: Int, newLabel: String) { + fun updateTimer(id: Int, settings: TimerSettings) { timerObjects.firstOrNull { it.id == id }?.let { - it.label.value = newLabel + it.label.value = settings.label + it.initialPosition.value = settings.seconds * 1000 + it.soundName = settings.soundName + it.soundUri = settings.soundUri + it.soundEnabled = settings.soundEnabled + it.vibrate = settings.vibrate + it.vibrationPattern = settings.vibrationPattern + it.vibrationPatternName = settings.vibrationPatternName updateNotification(it) } } - fun updateRingtone(id: Int, newRingtoneUri: Uri?) { - timerObjects.firstOrNull { it.id == id }?.let { - it.ringtone = newRingtoneUri - } - } - - fun updateVibrate(id: Int, vibrate: Boolean) { - timerObjects.firstOrNull { it.id == id }?.let { - it.vibrate = vibrate - } - } - override fun onDestroy() { releaseWakeLock() runCatching { diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 8b7c5b000..9ccf18d61 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 أبجدي قيمة مباعدة - عرض التحديد السريع ساعات دقائق ثواني @@ -119,7 +118,6 @@ عرض التاريخ حفظ حجم نص التاريخ - إضافة مؤقت مسبق بدء حذف الساعة العالمية البحث عن البلد أو المنطقة الزمنية @@ -151,7 +149,6 @@ النسخ الاحتياطي للمنبهات إلى ملف JSON الترحيل المنبه القادم - استخدام زر البداية الكبير سينطلق في %1$s تخطي أحمر @@ -194,8 +191,6 @@ لوحة الأرقام ساعة التوقيف مُتوقفة مؤقتًا ساعة التوقيف قيد التشغيل - المؤقت مُتوقف مؤقتًا - المؤقت قيد التشغيل مؤقت %1$s قيد التشغيل مؤقت %1$s مُتوقف مؤقتًا انتهى مؤقت %1$s diff --git a/app/src/main/res/values-az/strings.xml b/app/src/main/res/values-az/strings.xml index 5346e9da6..c5301e8bb 100644 --- a/app/src/main/res/values-az/strings.xml +++ b/app/src/main/res/values-az/strings.xml @@ -66,7 +66,6 @@ Sil Ertələ İmtina et - Taymer sürətli seçimin göstər Saat Saniyə Dövrə @@ -77,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-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index f1903939f..392d822b0 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -48,7 +48,6 @@ Часове Минути Секунди - Бърз избор на отброяване Отхвърляне Отлагане Същото време @@ -94,7 +93,6 @@ Показване на датата Запазване Приспособление за цифров часовник - Добавете предварително зададен таймер Старт Дата Размер на текста Разрешете известия, за да сте сигурни, че никога няма да пропуснете аларма или таймер. Ще изпращаме известия само за вашите зададени аларми и таймери. diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index a67f1229f..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 diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 98981c2d0..a1c542b02 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 @@ -99,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 @@ -132,7 +130,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 9f556e600..7fb304829 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -26,7 +26,6 @@ Slumre Afvis Samme tid - Vis hurtigvalg af timer Gentag Vælg Slumretid Alarmnavn @@ -105,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 2795b3938..2a34869ee 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 @@ -89,7 +88,6 @@ S Datum anzeigen Speichern - Voreingestellten Timer hinzufügen Datumstextgröße M Von @@ -132,7 +130,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 c341d0743..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 λεπτά πίσω @@ -89,7 +88,6 @@ Εμφάνιση ημερομηνίας Αποθήκευση Μέγεθος κειμένου Ημερομηνίας - Προσθήκη προκαθορισμένου χρονοδιακόπτη Έναρξη Μέγεθος κειμένου Χρόνου Ζώνη ώρας diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 0fc55213c..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 @@ -98,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 462472e4d..07f9473e4 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 @@ -97,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 @@ -137,7 +135,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 6ece1291b..6d41e891f 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -63,7 +63,6 @@ فاصله نمایش ثانیه‌ها نمایش دستور بن‌مایه‌ی برنامه - نمایش دست‌چین بیدرنگ زمان‌سنج واپَساندن رد‌کردن هم‌زمان @@ -90,7 +89,6 @@ زمان همادی نمایش تاریخ اندازه نوشتار تاریخ - افزودن زمان‌سنج پیش‌ساخت پاک‌کردن ساعت جهانی جستجو کشور/گستره‌ی زمانی اندازه‌ی نوشتار زمان diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 05a25172d..198f0c39a 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ää @@ -99,7 +98,6 @@ Näytä päiväys Tallenna Päiväyksen tekstin koko - Lisää esiasetusajastin Käynnistä Alkaa Päättyy diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 44ce39657..a170706b2 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 @@ -105,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. @@ -139,6 +137,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 a66383e8c..ef930c5d2 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -33,7 +33,6 @@ अनुरूप घड़ी डिजिटल घड़ी मिटाएं - टाइमर त्वरित चयन दिखाएं घंटे मिनट @@ -97,7 +96,6 @@ दिनांक दिखाएं सहेजें दिनांक पाठ का आकार - पूर्व निर्धारित टाइमर जोड़ें शुरू देश/समयक्षेत्र खोजें विश्व घड़ी मिटाएं diff --git a/app/src/main/res/values-ia/strings.xml b/app/src/main/res/values-ia/strings.xml index 03db2bc2c..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 diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 5db021832..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 @@ -91,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 ad24be572..fe2293614 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -53,7 +53,6 @@ V S Differenza - Mostra selezione rapida dei timer Seleziona durata di Posponi Nome della sveglia Una sola volta @@ -82,7 +81,6 @@ 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 @@ -95,7 +93,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 cc73b9e4f..15003d678 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -45,7 +45,6 @@ מחיקה אלפביתי היסט - הצגת בחירה מהירה של מתזמן שעות דקות שניות @@ -76,7 +75,6 @@ זמן לסבב הצגת תאריך גודל כתב תאריך - הוספת שעון עצר מוגדר ‌מ־ עד גודל כתב שעה @@ -128,7 +126,6 @@ הפעלה מחדש החלפת מצב פריטים שעון - להשתמש בכפתור התחלה גדול ייצוא התראות גיבוי התראות לקובץ json התראה בקרוב diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 45e236895..edea203b8 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -50,7 +50,6 @@ デジタル時計 縦時計 削除 - タイマーのクイック選択を表示 時間 diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 3b86b606a..a9eecc62b 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 @@ -100,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 @@ -142,13 +140,10 @@ 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 0d4655a2a..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 @@ -96,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 cf8c71a3f..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 diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 19d4125dd..6b189098e 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -45,7 +45,6 @@ Porniți temporizatorul\? Durată: %1$s Șterge - Afișați selecția rapidă a temporizatorului Ore Minute Secunde diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index b90c9b9a3..ebd593369 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 Удалить Запустить секундомер\? - Показать быстрый выбор таймера Часы Минуты Секунды @@ -102,7 +101,6 @@ Показывать дату Сохранить Размер шрифта даты - Добавить предустановку Начать Удалить мировое время Поиск по стране/часовому поясу @@ -132,7 +130,6 @@ Перезапустить Выбрать элементы Часы - Использовать большую кнопку \"старт\" Экспортировать будильники Сохранить будильники в json файл Ближайший будильник diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index 8f4fc4471..ba346f036 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -48,7 +48,6 @@ Тамна Изворни кôд Погледајте изворни кôд апликације - Прикажи брзи избор тајмера Сати Секунде Минути @@ -102,7 +101,6 @@ Сачувај Почетак Величина текста датума - Додај унапред подешени тајмер Претрага земље/временске зоне Избриши светски сат Прикажи позадину виџета diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml index 550e0be3d..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 @@ டிசிட்டல் கடிகாரம் செங்குத்து கடிகாரம் அழி - நேரங்குறிகருவி விரைவான தேர்வைக் காட்டு மணி நிமிடங்கள் diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index ca5e3d298..9291ee870 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -66,7 +66,6 @@ Dakika - Zamanlayıcı hızlı seçimini göster Saniye Başlangıç Bitiş @@ -98,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 @@ -127,7 +125,6 @@ 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 80581ef9a..251b385c9 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -45,7 +45,6 @@ Видалити Зміщення За алфавітом - Показати швидкий вибір таймера Години Хвилини Секунди @@ -104,7 +103,6 @@ Показувати дату Зберегти Розмір тексту дати - Додати передустановку таймера Розпочати Видалити світовий годинник Шукати країну/часовий пояс @@ -136,7 +134,6 @@ Деякі виробники використовують власні дозволи на доступ у фоновому режимі Вимкнути оптимізацію акумулятора Цей дозвіл потрібен для того, щоби таймери працювали, коли екран телефону вимкнено. - Використовувати велику кнопку запуску Експортувати будильники Зберегти копію будильників у json-файл Найближчий будильник diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index c41927ada..e19367dbc 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -56,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 @@ -65,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 cdd160dc8..c8a099bc3 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 @@ 删除 按字母顺序 偏移 - 显示快速选择 小时 分钟 @@ -89,7 +88,6 @@ 显示日期 保存 日期文本大小 - 添加预设定时器 总时间 启动 数字时钟微件 @@ -130,13 +128,10 @@ 迁移 导入闹钟 从 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 08a1f4cf5..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 @@ 關閉 - 顯示計時器快速選擇 小時 編輯鬧鐘 貪睡 @@ -65,7 +64,6 @@ 單圈時間 總時間 - 新增現在的計時器 顯示日期 儲存 日期文字大小 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4a02b1668..05c9f758a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -10,9 +10,7 @@ About Timer service %1$s timer is running - Timer is running %1$s timer is paused - Timer is paused %1$s timer finished Stopwatch is running Stopwatch is paused @@ -64,7 +62,6 @@ Toggle items Clock - Use big start button Export alarms Backup alarms to a JSON file Upcoming alarm @@ -101,7 +98,6 @@ Missed alarms %1$s alarm at %2$s was missed Alarm at %1$s was missed - Show quick selection Hours Minutes Seconds @@ -198,6 +194,14 @@ year years + + Active timer + Active timers + + + Saved timer + Saved timers + Every day Every %d days @@ -238,7 +242,7 @@ Show date Save Date text size - Add preset timer + Add saved timer Start Delete world clock Search country or time zone From 899e81df39eb67e616045dfdd629ab632f1509a0 Mon Sep 17 00:00:00 2001 From: Ris Peng Date: Fri, 28 Aug 2026 00:03:39 +0800 Subject: [PATCH 03/44] align alarm names with the time row --- .../clock/presentation/screens/alarm/components/AlarmCard.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 3626656b8..67896e034 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 @@ -65,7 +65,7 @@ fun AlarmCard( ?.minus(System.currentTimeMillis()) Row( modifier = Modifier - .padding(start = 5.dp, end = 10.dp) + .padding(end = 10.dp) .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { From 93a9e35ddaec132fcf0041944d6c4da0911a2579 Mon Sep 17 00:00:00 2001 From: Ris Peng Date: Fri, 28 Aug 2026 09:27:33 +0800 Subject: [PATCH 04/44] animate timer card insertion and removal Assign stable in-memory identifiers when reading saved timers so list animations, editing, and deletion remain attached to the correct item after positions change. Keep the stored timer format unchanged. --- .../bnyro/clock/domain/model/TimerSettings.kt | 9 +- .../features/TimerReceiverDialog.kt | 2 +- .../presentation/screens/timer/TimerScreen.kt | 95 +++++++++++-------- .../timer/components/SavedTimerItem.kt | 9 +- .../screens/timer/components/TimerItem.kt | 9 +- .../screens/timer/model/TimerModel.kt | 14 ++- 6 files changed, 89 insertions(+), 49 deletions(-) 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 index dd724dd37..cd1ebca7d 100644 --- a/app/src/main/java/com/bnyro/clock/domain/model/TimerSettings.kt +++ b/app/src/main/java/com/bnyro/clock/domain/model/TimerSettings.kt @@ -5,17 +5,21 @@ 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, @@ -31,7 +35,7 @@ data class TimerSettings( 60 * 15, 60 * 30, 60 * 60 - ).map { TimerSettings(it) } + ).mapIndexed { index, seconds -> TimerSettings(id = index + 1, seconds = seconds) } fun setSavedTimers(timers: List) { Preferences.edit { @@ -42,7 +46,8 @@ data class TimerSettings( fun getSavedTimers(): List { val savedTimers = Preferences.instance.getString(Preferences.savedTimersKey, null) ?: return exampleTimers - return Json.decodeFromString(savedTimers) + return Json.decodeFromString>(savedTimers) + .mapIndexed { index, timer -> timer.copy(id = index + 1) } } } } 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 c37d0424a..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 @@ -39,7 +39,7 @@ fun TimerReceiverDialog(duration: Int) { }, confirmButton = { DialogButton(R.string.start, DialogButtonStyle.PRIMARY) { - timerModel.startTimer(context, TimerSettings(duration)) + timerModel.startTimer(context, TimerSettings(seconds = duration)) showDialog = false } }, 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 a2667af28..4c1355cd6 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,5 +1,7 @@ package com.bnyro.clock.presentation.screens.timer +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -9,7 +11,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.PlayArrow @@ -28,6 +29,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import com.bnyro.clock.R import com.bnyro.clock.domain.model.TimerObject @@ -43,6 +45,9 @@ import com.bnyro.clock.presentation.screens.timer.components.TimerPickerSelector import com.bnyro.clock.presentation.screens.timer.model.TimerModel import com.bnyro.clock.util.extensions.KeepScreenOn +private val CARD_FADE = tween(durationMillis = 120, easing = FastOutSlowInEasing) +private val SECTION_SLIDE = tween(durationMillis = 220, easing = FastOutSlowInEasing) + @Composable fun TimerScreen( onClickSettings: () -> Unit, timerModel: TimerModel, settingsModel: SettingsModel @@ -51,7 +56,7 @@ fun TimerScreen( val activeTimers by timerModel.scheduledObjects.collectAsState() var editedTimer by remember { mutableStateOf(null) } - var editedSavedTimerIndex by remember { mutableStateOf(null) } + var editedSavedTimerId by remember { mutableStateOf(null) } TopBarScaffold( title = stringResource(R.string.timer), @@ -63,35 +68,41 @@ fun TimerScreen( .padding(paddingValues), contentPadding = PaddingValues(bottom = 16.dp) ) { - item { - TimerPickerSelector( - pickerStyle = settingsModel.timerPickerStyle, - seconds = timerModel.timePickerSeconds, - onSecondsChanged = { timerModel.timePickerSeconds = it } - ) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 16.dp), - horizontalArrangement = Arrangement.Center - ) { - LargeFloatingActionButton( - shape = CircleShape, - onClick = { - timerModel.startTimer( - context, - TimerSettings(timerModel.timePickerSeconds) - ) - } + item(key = "picker") { + Column { + TimerPickerSelector( + pickerStyle = settingsModel.timerPickerStyle, + seconds = timerModel.timePickerSeconds, + onSecondsChanged = { timerModel.timePickerSeconds = it } + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + horizontalArrangement = Arrangement.Center ) { - Icon(Icons.Default.PlayArrow, contentDescription = null) + LargeFloatingActionButton( + shape = CircleShape, + onClick = { + timerModel.startTimer( + context, + TimerSettings(seconds = timerModel.timePickerSeconds) + ) + } + ) { + Icon(Icons.Default.PlayArrow, contentDescription = null) + } } } } if (activeTimers.isNotEmpty()) { - item { - Column(modifier = Modifier.padding(horizontal = 16.dp)) { + item(key = "activeTimers") { + Column( + modifier = Modifier + .animateItem(CARD_FADE, SECTION_SLIDE, CARD_FADE) + .padding(horizontal = 16.dp) + ) { HorizontalDivider( modifier = Modifier.padding(top = 12.dp, bottom = 8.dp), color = MaterialTheme.colorScheme.surfaceVariant @@ -102,12 +113,21 @@ fun TimerScreen( } } items(activeTimers, key = { it.id }) { timer -> - TimerItem(timer, timerModel, onEdit = { editedTimer = timer }) + TimerItem( + obj = timer, + timerModel = timerModel, + onEdit = { editedTimer = timer }, + modifier = Modifier.animateItem(CARD_FADE, SECTION_SLIDE, CARD_FADE) + ) } } - item { - Column(modifier = Modifier.padding(horizontal = 16.dp)) { + item(key = "savedTimers") { + Column( + modifier = Modifier + .animateItem(CARD_FADE, SECTION_SLIDE, CARD_FADE) + .padding(horizontal = 16.dp) + ) { HorizontalDivider( modifier = Modifier.padding(top = 12.dp, bottom = 8.dp), color = MaterialTheme.colorScheme.surfaceVariant @@ -124,11 +144,12 @@ fun TimerScreen( } } } - itemsIndexed(timerModel.savedTimers) { index, timer -> + items(timerModel.savedTimers, key = { it.id }) { timer -> SavedTimerItem( timer = timer, onStart = { timerModel.startTimer(context, timer) }, - onEdit = { editedSavedTimerIndex = index } + onEdit = { editedSavedTimerId = timer.id }, + modifier = Modifier.animateItem(CARD_FADE, SECTION_SLIDE, CARD_FADE) ) } } @@ -150,19 +171,19 @@ fun TimerScreen( ) } - editedSavedTimerIndex?.let { index -> + editedSavedTimerId?.let { id -> TimerEditSheet( - currentTimer = timerModel.savedTimers[index], + currentTimer = timerModel.savedTimers.first { it.id == id }, pickerStyle = settingsModel.timerPickerStyle, onSave = { settings -> - timerModel.updateSavedTimer(index, settings) - editedSavedTimerIndex = null + timerModel.updateSavedTimer(settings) + editedSavedTimerId = null }, onDelete = { - timerModel.removeSavedTimer(index) - editedSavedTimerIndex = null + timerModel.removeSavedTimer(id) + editedSavedTimerId = null }, - onDismiss = { editedSavedTimerIndex = null } + onDismiss = { editedSavedTimerId = null } ) } } 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 index a412ee93d..10de85e69 100644 --- 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 @@ -25,13 +25,18 @@ import com.bnyro.clock.domain.model.TimerSettings import com.bnyro.clock.util.extensions.addZero @Composable -fun SavedTimerItem(timer: TimerSettings, onStart: () -> Unit, onEdit: () -> Unit) { +fun SavedTimerItem( + timer: TimerSettings, + onStart: () -> Unit, + onEdit: () -> Unit, + modifier: Modifier = Modifier +) { val hours = timer.seconds / 3600 val minutes = timer.seconds % 3600 / 60 val seconds = timer.seconds % 60 ElevatedCard( - modifier = Modifier + modifier = modifier .padding(horizontal = 12.dp, vertical = 6.dp) .clickable(onClick = onEdit), shape = RoundedCornerShape(20.dp), 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 b18c78938..ed6ebb32a 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 @@ -43,7 +43,12 @@ import com.bnyro.clock.util.extensions.addZero import java.time.ZonedDateTime @Composable -fun TimerItem(obj: TimerObject, timerModel: TimerModel, onEdit: () -> Unit) { +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 @@ -51,7 +56,7 @@ fun TimerItem(obj: TimerObject, timerModel: TimerModel, onEdit: () -> Unit) { val seconds = (obj.currentPosition.value % 60000) / 1000 ElevatedCard( - modifier = Modifier + modifier = modifier .padding(horizontal = 12.dp, vertical = 6.dp) .clickable(onClick = onEdit), shape = RoundedCornerShape(20.dp), 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 d50d6de18..8f6f9918a 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 @@ -38,17 +38,21 @@ class TimerModel : ViewModel() { _timerObjects.value = listOf(*objects) } - fun removeSavedTimer(index: Int) { - savedTimers = savedTimers.filterIndexed { i, _ -> i != index } + fun removeSavedTimer(id: Int) { + savedTimers = savedTimers.filter { it.id != id } } fun addSavedTimer(seconds: Int) { if (seconds == 0) return - savedTimers = (savedTimers + TimerSettings(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 updateSavedTimer(index: Int, settings: TimerSettings) { - savedTimers = savedTimers.mapIndexed { i, timer -> if (i == index) settings else timer } + fun updateSavedTimer(settings: TimerSettings) { + savedTimers = savedTimers.map { if (it.id == settings.id) settings else it } } fun startTimer(context: Context, settings: TimerSettings) { From 91765ade26d07f37e64a0284edb306fc1c6012e7 Mon Sep 17 00:00:00 2001 From: Ris Peng Date: Fri, 28 Aug 2026 09:59:17 +0800 Subject: [PATCH 05/44] share list animations and add saved timer selection Move card animations into the theme and reuse them for alarms, timers, and stopwatch laps. Key alarm rows by alarm identity so toggling an alarm does not animate it as a replacement. Scroll to a new lap only after the table exceeds its visible capacity. Support long-press saved timer selection with copy, delete, and dismiss actions. Share the lowercase selection count string and clip card press ripples to their corners. --- .../components/SwitchWithDivider.kt | 7 +- .../presentation/screens/alarm/AlarmScreen.kt | 13 ++- .../screens/alarm/components/AlarmItem.kt | 6 +- .../screens/stopwatch/StopwatchScreen.kt | 22 +++- .../presentation/screens/timer/TimerScreen.kt | 110 +++++++++++++++--- .../timer/components/SavedTimerItem.kt | 27 ++++- .../screens/timer/components/TimerItem.kt | 5 +- .../screens/timer/model/TimerModel.kt | 6 + .../java/com/bnyro/clock/ui/theme/Motion.kt | 18 +++ app/src/main/res/values/strings.xml | 2 + 10 files changed, 177 insertions(+), 39 deletions(-) create mode 100644 app/src/main/java/com/bnyro/clock/ui/theme/Motion.kt 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 7ee65754c..59331930b 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.Text 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/screens/alarm/AlarmScreen.kt b/app/src/main/java/com/bnyro/clock/presentation/screens/alarm/AlarmScreen.kt index 51fe12001..6e5f9605a 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 @@ -53,6 +53,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 @@ -76,7 +78,7 @@ fun AlarmScreen( TopBarScaffold( title = if (isSelectionMode) { - "${selectedAlarmIds.size} Selected" + stringResource(R.string.selected_count, selectedAlarmIds.size) } else { stringResource(R.string.alarm) }, @@ -177,7 +179,7 @@ fun AlarmScreen( .fillMaxSize() .padding(pv) ) { - item { + item(key = "filters") { if (alarmModel.showFilter) { AlarmFilterSection( filters, @@ -191,7 +193,7 @@ fun AlarmScreen( items( items = alarms, - key = { it.id.toString() + "-" + it.enabled } + key = { it.id } ) { alarm -> val isSelected = selectedAlarmIds.contains(alarm.id) @@ -229,11 +231,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/AlarmItem.kt b/app/src/main/java/com/bnyro/clock/presentation/screens/alarm/components/AlarmItem.kt index 26c228ee2..3856b6654 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/stopwatch/StopwatchScreen.kt b/app/src/main/java/com/bnyro/clock/presentation/screens/stopwatch/StopwatchScreen.kt index f5c3ecaa2..4c4e19112 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 @@ -54,6 +54,8 @@ 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.util.extensions.KeepScreenOn import com.bnyro.clock.util.extensions.addZero import kotlinx.coroutines.CoroutineScope @@ -152,11 +154,15 @@ private fun StopwatchController( FloatingActionButton( containerColor = MaterialTheme.colorScheme.tertiaryContainer, onClick = { + val tableOverflows = timeStampsState.canScrollForward || + timeStampsState.canScrollBackward stopwatchModel.onLapClicked() - scope.launch { - timeStampsState.scrollToItem( - stopwatchModel.rememberedTimeStamps.size - 1 - ) + if (tableOverflows) { + scope.launch { + timeStampsState.scrollToItem( + stopwatchModel.rememberedTimeStamps.size - 1 + ) + } } } ) { @@ -222,7 +228,7 @@ private fun LapTable( .padding(16.dp), state = timeStampsState ) { - item { + item(key = "header") { Column { Row { Text( @@ -247,9 +253,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/TimerScreen.kt b/app/src/main/java/com/bnyro/clock/presentation/screens/timer/TimerScreen.kt index 4c1355cd6..2c8e37341 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,7 +1,5 @@ package com.bnyro.clock.presentation.screens.timer -import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -13,15 +11,21 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons +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.AlertDialog import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.LargeFloatingActionButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable 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 @@ -29,13 +33,14 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import com.bnyro.clock.R import com.bnyro.clock.domain.model.TimerObject 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.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.SavedTimerItem @@ -43,11 +48,10 @@ 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.ui.theme.ItemFade +import com.bnyro.clock.ui.theme.ItemSlide import com.bnyro.clock.util.extensions.KeepScreenOn -private val CARD_FADE = tween(durationMillis = 120, easing = FastOutSlowInEasing) -private val SECTION_SLIDE = tween(durationMillis = 220, easing = FastOutSlowInEasing) - @Composable fun TimerScreen( onClickSettings: () -> Unit, timerModel: TimerModel, settingsModel: SettingsModel @@ -58,9 +62,37 @@ fun TimerScreen( var editedTimer by remember { mutableStateOf(null) } var editedSavedTimerId by remember { mutableStateOf(null) } + val selectedSavedTimerIds = remember { mutableStateListOf() } + val isSelectionMode = selectedSavedTimerIds.isNotEmpty() + var showDeletionDialog by remember { mutableStateOf(false) } + TopBarScaffold( - title = stringResource(R.string.timer), - onClickSettings = onClickSettings + title = if (isSelectionMode) { + stringResource(R.string.selected_count, selectedSavedTimerIds.size) + } else { + stringResource(R.string.timer) + }, + onClickSettings = if (isSelectionMode) { + { selectedSavedTimerIds.clear() } + } else { + onClickSettings + }, + actions = { + if (isSelectionMode) { + ClickableIcon(imageVector = Icons.Default.ContentCopy) { + timerModel.savedTimers + .filter { selectedSavedTimerIds.contains(it.id) } + .forEach { timerModel.copySavedTimer(it) } + selectedSavedTimerIds.clear() + } + ClickableIcon(imageVector = Icons.Default.Delete) { + showDeletionDialog = true + } + ClickableIcon(imageVector = Icons.Default.Close) { + selectedSavedTimerIds.clear() + } + } + } ) { paddingValues -> LazyColumn( modifier = Modifier @@ -100,7 +132,7 @@ fun TimerScreen( item(key = "activeTimers") { Column( modifier = Modifier - .animateItem(CARD_FADE, SECTION_SLIDE, CARD_FADE) + .animateItem(ItemFade, ItemSlide, ItemFade) .padding(horizontal = 16.dp) ) { HorizontalDivider( @@ -117,7 +149,7 @@ fun TimerScreen( obj = timer, timerModel = timerModel, onEdit = { editedTimer = timer }, - modifier = Modifier.animateItem(CARD_FADE, SECTION_SLIDE, CARD_FADE) + modifier = Modifier.animateItem(ItemFade, ItemSlide, ItemFade) ) } } @@ -125,7 +157,7 @@ fun TimerScreen( item(key = "savedTimers") { Column( modifier = Modifier - .animateItem(CARD_FADE, SECTION_SLIDE, CARD_FADE) + .animateItem(ItemFade, ItemSlide, ItemFade) .padding(horizontal = 16.dp) ) { HorizontalDivider( @@ -135,21 +167,41 @@ fun TimerScreen( SettingsCategory( pluralStringResource(R.plurals.saved_timers, timerModel.savedTimers.size) ) { - ClickableIcon( - imageVector = Icons.Rounded.AddAlarm, - contentDescription = stringResource(R.string.add_saved_timer) - ) { - timerModel.addSavedTimer(timerModel.timePickerSeconds) + if (!isSelectionMode) { + ClickableIcon( + imageVector = Icons.Rounded.AddAlarm, + contentDescription = stringResource(R.string.add_saved_timer) + ) { + timerModel.addSavedTimer(timerModel.timePickerSeconds) + } } } } } items(timerModel.savedTimers, key = { it.id }) { timer -> + val isSelected = selectedSavedTimerIds.contains(timer.id) + SavedTimerItem( timer = timer, - onStart = { timerModel.startTimer(context, timer) }, - onEdit = { editedSavedTimerId = timer.id }, - modifier = Modifier.animateItem(CARD_FADE, SECTION_SLIDE, CARD_FADE) + isSelected = isSelected, + onStart = { + if (!isSelectionMode) timerModel.startTimer(context, timer) + }, + onClick = { + if (isSelectionMode) { + if (isSelected) { + selectedSavedTimerIds.remove(timer.id) + } else { + selectedSavedTimerIds.add(timer.id) + } + } else { + editedSavedTimerId = timer.id + } + }, + onLongClick = { + if (!isSelectionMode) selectedSavedTimerIds.add(timer.id) + }, + modifier = Modifier.animateItem(ItemFade, ItemSlide, ItemFade) ) } } @@ -171,6 +223,26 @@ fun TimerScreen( ) } + 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 }, 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 index 10de85e69..40c98fd65 100644 --- 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 @@ -1,6 +1,8 @@ package com.bnyro.clock.presentation.screens.timer.components -import androidx.compose.foundation.clickable +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.fillMaxWidth @@ -18,28 +20,45 @@ 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, - onEdit: () -> 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) - .clickable(onClick = onEdit), - shape = RoundedCornerShape(20.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( 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 ed6ebb32a..dcee3217a 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 @@ -29,6 +29,7 @@ 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.graphics.StrokeCap import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight @@ -54,12 +55,14 @@ fun TimerItem( val hours = obj.currentPosition.value / 3600000 val minutes = (obj.currentPosition.value % 3600000) / 60000 val seconds = (obj.currentPosition.value % 60000) / 1000 + val cardShape = RoundedCornerShape(20.dp) ElevatedCard( modifier = modifier .padding(horizontal = 12.dp, vertical = 6.dp) + .clip(cardShape) .clickable(onClick = onEdit), - shape = RoundedCornerShape(20.dp), + shape = cardShape, colors = CardDefaults.elevatedCardColors() ) { Column { 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 8f6f9918a..22c408079 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 @@ -51,6 +51,12 @@ class TimerModel : ViewModel() { ) } + 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 } } 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..92bf47876 --- /dev/null +++ b/app/src/main/java/com/bnyro/clock/ui/theme/Motion.kt @@ -0,0 +1,18 @@ +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 + +/** + * The weight a card takes on as it arrives in a list, and gives up as it leaves. + */ +val ItemFade: FiniteAnimationSpec = + tween(durationMillis = 120, 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) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 05c9f758a..e45b63a55 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -22,8 +22,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 From 40de39f010a01650548e760f562d11d7be364132 Mon Sep 17 00:00:00 2001 From: Ris Peng Date: Fri, 28 Aug 2026 10:41:08 +0800 Subject: [PATCH 06/44] animate lap table growth and center stopwatch controls Animate the table height as laps are appended. Reserve both side-button slots so showing or hiding lap and stop controls does not shift the main button. --- .../screens/stopwatch/StopwatchScreen.kt | 93 +++++++++++-------- .../java/com/bnyro/clock/ui/theme/Motion.kt | 7 ++ 2 files changed, 63 insertions(+), 37 deletions(-) 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 4c4e19112..2f0b9b55e 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,7 @@ 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.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -56,11 +57,14 @@ 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 @@ -149,26 +153,26 @@ private fun StopwatchController( horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically ) { - AnimatedVisibility(stopwatchModel.state == WatchState.RUNNING) { - Row { - FloatingActionButton( - containerColor = MaterialTheme.colorScheme.tertiaryContainer, - onClick = { - val tableOverflows = timeStampsState.canScrollForward || - timeStampsState.canScrollBackward - stopwatchModel.onLapClicked() - if (tableOverflows) { - scope.launch { - timeStampsState.scrollToItem( - stopwatchModel.rememberedTimeStamps.size - 1 - ) - } + 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( @@ -186,33 +190,47 @@ 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 +) { + Box( + modifier = Modifier.width(SIDE_CONTROL_SLOT), + contentAlignment = alignment + ) { + AnimatedVisibility(visible) { content() } + } +} + @Composable private fun LapTable( modifier: Modifier = Modifier, @@ -225,6 +243,7 @@ private fun LapTable( RoundedCornerShape(16.dp) ) .background(MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)) + .animateContentSize(ListResize) .padding(16.dp), state = timeStampsState ) { 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 index 92bf47876..4d025519a 100644 --- a/app/src/main/java/com/bnyro/clock/ui/theme/Motion.kt +++ b/app/src/main/java/com/bnyro/clock/ui/theme/Motion.kt @@ -4,6 +4,7 @@ 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. @@ -16,3 +17,9 @@ val ItemFade: FiniteAnimationSpec = */ 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) From 588618669106f58f45d3c2af6eeaaa2732b11e32 Mon Sep 17 00:00:00 2001 From: Ris Peng Date: Fri, 28 Aug 2026 10:58:09 +0800 Subject: [PATCH 07/44] animate stopwatch side buttons from behind the main button Translate each side button by its slot width and draw the main button above them so overlapping portions remain hidden during entry and exit. --- .../screens/stopwatch/StopwatchScreen.kt | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) 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 2f0b9b55e..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 @@ -7,6 +7,8 @@ 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 @@ -48,9 +50,11 @@ 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 @@ -176,6 +180,7 @@ private fun StopwatchController( } } LargeFloatingActionButton( + modifier = Modifier.zIndex(1f), shape = CircleShape, onClick = { stopwatchModel.pauseResumeStopwatch(context) @@ -223,11 +228,24 @@ private fun SideControl( 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) { content() } + AnimatedVisibility( + visible = visible, + enter = slideInHorizontally(ItemSlide) { behindMainControl }, + exit = slideOutHorizontally(ItemSlide) { behindMainControl } + ) { + content() + } } } From 9cf14458b4b45b84d59fb88d14bc606d89d7118d Mon Sep 17 00:00:00 2001 From: Ris Peng Date: Fri, 28 Aug 2026 11:21:23 +0800 Subject: [PATCH 08/44] add timer names and controls to notifications Show each timer's label and paused state with an hourglass icon. Offer pause, add time, and stop while running, and resume, reset, and stop while paused. Add one minute to the remaining countdown without changing the restart duration and reschedule the expiry alarm. Expose the same increment on the timer card using an available clock-plus icon. --- .../com/bnyro/clock/navigation/HomeRoutes.kt | 4 +- .../screens/timer/components/TimerItem.kt | 5 +++ .../screens/timer/model/TimerModel.kt | 13 ++++++ .../bnyro/clock/util/services/TimerService.kt | 44 +++++++++++-------- app/src/main/res/drawable/ic_timer.xml | 9 ++++ app/src/main/res/values-ar/strings.xml | 3 -- app/src/main/res/values-bo/strings.xml | 1 - app/src/main/res/values-cs/strings.xml | 1 - app/src/main/res/values-de/strings.xml | 1 - app/src/main/res/values-et/strings.xml | 1 - app/src/main/res/values-fi/strings.xml | 1 - app/src/main/res/values-fr/strings.xml | 1 - app/src/main/res/values-it/strings.xml | 1 - app/src/main/res/values-iw/strings.xml | 1 - app/src/main/res/values-pl/strings.xml | 3 -- app/src/main/res/values-ru/strings.xml | 1 - app/src/main/res/values-tr/strings.xml | 1 - app/src/main/res/values-uk/strings.xml | 1 - app/src/main/res/values-zh-rCN/strings.xml | 3 -- app/src/main/res/values/strings.xml | 6 +-- 20 files changed, 58 insertions(+), 43 deletions(-) create mode 100644 app/src/main/res/drawable/ic_timer.xml 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/presentation/screens/timer/components/TimerItem.kt b/app/src/main/java/com/bnyro/clock/presentation/screens/timer/components/TimerItem.kt index dcee3217a..f65f8300c 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 @@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close +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 @@ -156,6 +157,10 @@ fun TimerItem( horizontalArrangement = Arrangement.spacedBy((-10).dp), verticalAlignment = Alignment.CenterVertically ) { + ClickableIcon(imageVector = Icons.Default.MoreTime) { + timerModel.addMinuteToTimer(context, obj.id) + } + ClickableIcon(imageVector = Icons.Default.Refresh) { timerModel.restartTimer(context, obj.id) } 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 22c408079..b60a1add6 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 @@ -109,6 +109,19 @@ class TimerModel : ViewModel() { context.sendBroadcast(stopIntent) } + fun addMinuteToTimer(context: Context, index: Int) { + val addMinuteIntent = Intent(TimerService.UPDATE_STATE_ACTION) + .putExtra( + TimerService.ID_EXTRA_KEY, + index + ) + .putExtra( + TimerService.ACTION_EXTRA_KEY, + TimerService.ACTION_ADD_MINUTE + ) + context.sendBroadcast(addMinuteIntent) + } + fun restartTimer(context: Context, index: Int) { val restartIntent = Intent(TimerService.UPDATE_STATE_ACTION) .putExtra( 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 82ffa6f14..8bd8867c0 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 @@ -119,8 +119,12 @@ class TimerService : Service() { if (obj.state.value == WatchState.PAUSED) resume(obj) else pause(obj) } - ACTION_ADD_5_MIN -> { - obj.currentPosition.value += 300000 + ACTION_ADD_MINUTE -> { + obj.currentPosition.value += 60000 + if (obj.state.value == WatchState.RUNNING) { + cancelAlarm(obj) + scheduleAlarm(obj) + } updateNotification(obj) } @@ -295,14 +299,11 @@ class TimerService : Service() { private fun getNotification(timerObject: TimerObject) = NotificationCompat.Builder( this, NotificationHelper.TIMER_CHANNEL ).setContentTitle( - getString( - if (timerObject.state.value == WatchState.RUNNING) { - R.string.running_named_timer - } else { - R.string.paused_named_timer - }, + if (timerObject.state.value == WatchState.RUNNING) { timerObject.label.value - ) + } else { + getString(R.string.paused_timer_title, timerObject.label.value) + } ) .setContentIntent(contentIntent) .apply { @@ -327,9 +328,16 @@ class TimerService : Service() { setShowWhen(false) } } - .addAction(stopAction(timerObject)).addAction(pauseResumeAction(timerObject)) - .addAction(restarttimer(timerObject)).addAction(add5MinAction(timerObject)) - .setSmallIcon(R.drawable.ic_notification).setOngoing(true).build() + .addAction(pauseResumeAction(timerObject)) + .addAction( + if (timerObject.state.value == WatchState.RUNNING) { + addMinuteAction(timerObject) + } else { + resetAction(timerObject) + } + ) + .addAction(stopAction(timerObject)) + .setSmallIcon(R.drawable.ic_timer).setOngoing(true).build() fun invokeChangeListener() { onChangeTimers.invoke(timerObjects.toTypedArray()) @@ -460,7 +468,7 @@ class TimerService : Service() { ) val notification = NotificationCompat.Builder(this, notificationChannelId) - .setSmallIcon(R.drawable.ic_notification) + .setSmallIcon(R.drawable.ic_timer) .setSilent(true) .setContentTitle(getString(R.string.finished_named_timer, timerObject.label.value)) .setContentIntent(contentIntent) @@ -501,12 +509,12 @@ class TimerService : Service() { 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( + 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 addMinuteAction(timerObject: TimerObject) = getAction( + R.string.add_one_minute, ACTION_ADD_MINUTE, 6, timerObject.id ) fun updateTimer(id: Int, settings: TimerSettings) { @@ -547,7 +555,7 @@ 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_MINUTE = "add_minute" 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 9ccf18d61..dabf89041 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -54,7 +54,6 @@ نفس الوقت تكرار إعادة البدء - ٥ دقائق عناصر التبديل الساعة @@ -191,8 +190,6 @@ لوحة الأرقام ساعة التوقيف مُتوقفة مؤقتًا ساعة التوقيف قيد التشغيل - مؤقت %1$s قيد التشغيل - مؤقت %1$s مُتوقف مؤقتًا انتهى مؤقت %1$s أنه رن لمدة %d دقيقة diff --git a/app/src/main/res/values-bo/strings.xml b/app/src/main/res/values-bo/strings.xml index ee8caf8cc..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 - སྐར་མ་ལྔ། བསྐྱར་དུ་འགོ་འཛུགས། ཀ་མད་ལྟར། མཐོང་རྣམ། diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index a1c542b02..6a8c697ea 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -121,7 +121,6 @@ Přidat %d minut Barva textu data Barva textu času - 5 min Restart Hodiny Zkontrolujte to ještě jednou! diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 2a34869ee..d86b0b329 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -119,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 diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 07f9473e4..5ce830ad5 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -120,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 diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 198f0c39a..b7428fce2 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -116,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 a170706b2..2b69b4792 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -120,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 diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index fe2293614..dd9aa7c4b 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -76,7 +76,6 @@ Ripeti Scheda iniziale Elimina sveglia(e) - 5 min Riavvia Attiva/disattiva elementi Orologio diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index 15003d678..66d905ec4 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -122,7 +122,6 @@ צבע כתב תאריך צבע כתב שעה מחיקת התראה/ות - 5 דק׳ הפעלה מחדש החלפת מצב פריטים שעון diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index a9eecc62b..9a1223936 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -126,7 +126,6 @@ Wybierz wzór wibracji Widżet zegara cyfrowego Usuń alarm(y) - 5 minut Uruchom ponownie Wybrane zakładki Zegar @@ -142,8 +141,6 @@ Importuj alarmy z Fossify Nadchodzący alarm Włączy się o %1$s - Minutnik %1$s jest uruchomiony - Minutnik (%1$s) jest wstrzymany Minutnik %1$s zakończył odliczanie Stoper uruchomiony Stoper wstrzymany diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index ebd593369..030fa8ed9 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -126,7 +126,6 @@ Цвет текста даты Цвет текста времени Удалить будильник(-и) - 5 мин Перезапустить Выбрать элементы Часы diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 9291ee870..fe28172c5 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -121,7 +121,6 @@ Tarih metni rengi Zaman metni rengi Alarm(lar)ı sil - 5 Dak Yeniden başlat Ögeleri değiştir Saat diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 251b385c9..e1fdb6308 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -126,7 +126,6 @@ Колір тексту часу Колір тексту дати Видалити будильник(-и) - 5 хв Перезапустити Перемкнути елементи Годинник diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index c8a099bc3..e8f2654d9 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -113,7 +113,6 @@ 减少 %d 分钟 日期文本颜色 时间文本颜色 - 5 分钟 重启 切换项目 时钟 @@ -130,8 +129,6 @@ 从 Fossify 导入闹钟 预定的闹钟 将在 %1$s 响起 - %1$s 定时器正在运行 - %1$s 定时器已暂停 %1$s 定时器已结束 秒表正在运行 秒表已暂停 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e45b63a55..af95149f0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -9,8 +9,6 @@ Settings About Timer service - %1$s timer is running - %1$s timer is paused %1$s timer finished Stopwatch is running Stopwatch is paused @@ -45,8 +43,10 @@ Default Start timer? Duration: %1$s - 5 Min + Add 1 min Restart + Reset + %1$s paused Alphabetic Offset From 2accf77344eb034d2e9a83498b85f432fe5baa65 Mon Sep 17 00:00:00 2001 From: Ris Peng Date: Fri, 28 Aug 2026 12:20:09 +0800 Subject: [PATCH 09/44] fix timer notification routing and add picker preferences Move the countdown into the notification title and refresh it once per second, with the timer name and paused state beneath it. Include the timer identifier and action in intent data to prevent pending intent collisions. Reuse the existing app activity when opening timer and stopwatch notifications and select the corresponding tab. Allow the timer picker to hide while timers run and expose preferences for its visibility, the start button size, and the increment amount. Apply the same increment to notifications and initialize the picker to one minute. --- .../domain/model/TimerPickerBehaviour.kt | 6 + .../screens/settings/SettingsScreen.kt | 61 ++++++++++ .../screens/settings/model/SettingsModel.kt | 12 ++ .../presentation/screens/timer/TimerScreen.kt | 87 +++++++++++--- .../screens/timer/components/TimerItem.kt | 2 +- .../timer/components/TimerPickerSelector.kt | 12 ++ .../screens/timer/model/TimerModel.kt | 54 ++------- .../java/com/bnyro/clock/util/Preferences.kt | 3 + .../clock/util/services/StopwatchService.kt | 4 +- .../bnyro/clock/util/services/TimerService.kt | 110 +++++++++--------- app/src/main/res/values/strings.xml | 16 +++ 11 files changed, 246 insertions(+), 121 deletions(-) create mode 100644 app/src/main/java/com/bnyro/clock/domain/model/TimerPickerBehaviour.kt 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..6db59e473 --- /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 { + KEEP_OPEN, + HIDE +} 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 73f4898f7..04a8e226a 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 @@ -15,6 +15,7 @@ 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 +42,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.navigation.NavRoutes import com.bnyro.clock.navigation.homeRoutes @@ -68,6 +70,12 @@ fun SettingsScreen( rememberTopAppBarState() ) var showAlarmTimeoutDialog by remember { mutableStateOf(false) } + var showTimerIncrementDialog by remember { mutableStateOf(false) } + var timerIncrementSeconds by remember { + mutableIntStateOf( + Preferences.instance.getInt(Preferences.timerIncrementSecondsKey, 60) + ) + } var alarmTimeoutMinutes by remember { mutableIntStateOf( Preferences.instance.getInt( @@ -289,6 +297,43 @@ fun SettingsScreen( Preferences.edit { putString(Preferences.timerPickerStyleKey, it.name) } } + ButtonGroupPref( + title = stringResource(R.string.timer_picker_behaviour), + options = TimerPickerBehaviour.entries.map { + stringResource( + when (it) { + TimerPickerBehaviour.KEEP_OPEN -> R.string.picker_keep_open + TimerPickerBehaviour.HIDE -> R.string.picker_hide + } + ) + }, + values = TimerPickerBehaviour.entries, + currentValue = settingsModel.timerPickerBehaviour + ) { + settingsModel.timerPickerBehaviour = it + Preferences.edit { putString(Preferences.timerPickerBehaviourKey, it.name) } + } + + SwitchPref( + prefKey = Preferences.timerBigStartButtonKey, + title = stringResource(R.string.timer_use_big_start), + defaultValue = true + ) { + settingsModel.timerBigStartButton = it + } + + IconPreference( + title = stringResource(R.string.timer_increment), + summary = pluralStringResource( + R.plurals.seconds_count, + timerIncrementSeconds, + timerIncrementSeconds + ), + imageVector = Icons.Rounded.MoreTime + ) { + showTimerIncrementDialog = true + } + HorizontalDivider( modifier = Modifier.padding(top = 12.dp, bottom = 8.dp), color = MaterialTheme.colorScheme.surfaceVariant @@ -355,6 +400,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 }, 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 db0be79b5..e084171e8 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.usecase.CreateUpdateDeleteAlarmUseCase import com.bnyro.clock.navigation.HomeRoutes import com.bnyro.clock.navigation.homeRoutes @@ -60,6 +61,17 @@ class SettingsModel : ViewModel() { ) ?: PickerStyle.WHEEL.name ) ) + var timerPickerBehaviour by mutableStateOf( + TimerPickerBehaviour.valueOf( + Preferences.instance.getString( + Preferences.timerPickerBehaviourKey, + TimerPickerBehaviour.KEEP_OPEN.name + ) ?: TimerPickerBehaviour.KEEP_OPEN.name + ) + ) + var timerBigStartButton by mutableStateOf( + Preferences.instance.getBoolean(Preferences.timerBigStartButtonKey, true) + ) var alarmPickerStyle by mutableStateOf( PickerStyle.valueOf( Preferences.instance.getString( 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 2c8e37341..8be114fbf 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 @@ -7,7 +7,9 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons @@ -16,13 +18,17 @@ 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.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.Text 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 @@ -36,6 +42,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.bnyro.clock.R 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 @@ -62,6 +69,23 @@ fun TimerScreen( 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(settingsModel.timerPickerBehaviour == TimerPickerBehaviour.KEEP_OPEN) + } + LaunchedEffect(activeTimers.isEmpty(), settingsModel.timerPickerBehaviour) { + if (activeTimers.isNotEmpty() && + settingsModel.timerPickerBehaviour == TimerPickerBehaviour.HIDE + ) { + showPicker = false + } + } + + // taking the picker away shortens what is above the list, so the list follows it up + val listState = rememberLazyListState() + LaunchedEffect(showPicker) { listState.scrollToItem(0) } + val selectedSavedTimerIds = remember { mutableStateListOf() } val isSelectionMode = selectedSavedTimerIds.isNotEmpty() var showDeletionDialog by remember { mutableStateOf(false) } @@ -98,29 +122,41 @@ fun TimerScreen( modifier = Modifier .fillMaxSize() .padding(paddingValues), + state = listState, contentPadding = PaddingValues(bottom = 16.dp) ) { - item(key = "picker") { - Column { + if (activeTimers.isEmpty() || showPicker) { + item(key = "picker") { TimerPickerSelector( + modifier = Modifier.animateItem(ItemFade, ItemSlide, ItemFade), pickerStyle = settingsModel.timerPickerStyle, seconds = timerModel.timePickerSeconds, onSecondsChanged = { timerModel.timePickerSeconds = it } ) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 16.dp), - horizontalArrangement = Arrangement.Center - ) { - LargeFloatingActionButton( - shape = CircleShape, - onClick = { - timerModel.startTimer( - context, - TimerSettings(seconds = timerModel.timePickerSeconds) - ) - } + } + } + item(key = "startButton") { + Row( + modifier = Modifier + .animateItem(ItemFade, ItemSlide, ItemFade) + .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) } @@ -141,7 +177,24 @@ fun TimerScreen( ) 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 + } + } } } items(activeTimers, key = { it.id }) { timer -> 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 f65f8300c..a37a0b2f4 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 @@ -158,7 +158,7 @@ fun TimerItem( verticalAlignment = Alignment.CenterVertically ) { ClickableIcon(imageVector = Icons.Default.MoreTime) { - timerModel.addMinuteToTimer(context, obj.id) + timerModel.addTimeToTimer(context, obj.id) } ClickableIcon(imageVector = Icons.Default.Refresh) { 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 index 626335797..ffcd9e811 100644 --- 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 @@ -19,6 +19,18 @@ 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 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 b60a1add6..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 @@ -32,7 +32,7 @@ class TimerModel : ViewModel() { } ) - var timePickerSeconds by mutableStateOf(0) + var timePickerSeconds by mutableStateOf(60) fun onChangeTimers(objects: Array) { _timerObjects.value = listOf(*objects) @@ -83,55 +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 pauseResumeTimer(context: Context, id: Int) { + context.sendBroadcast(TimerService.updateStateIntent(TimerService.ACTION_PAUSE_RESUME, id)) } - 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 stopTimer(context: Context, id: Int) { + context.sendBroadcast(TimerService.updateStateIntent(TimerService.ACTION_STOP, id)) } - fun addMinuteToTimer(context: Context, index: Int) { - val addMinuteIntent = Intent(TimerService.UPDATE_STATE_ACTION) - .putExtra( - TimerService.ID_EXTRA_KEY, - index - ) - .putExtra( - TimerService.ACTION_EXTRA_KEY, - TimerService.ACTION_ADD_MINUTE - ) - context.sendBroadcast(addMinuteIntent) + fun addTimeToTimer(context: Context, id: Int) { + context.sendBroadcast(TimerService.updateStateIntent(TimerService.ACTION_ADD_TIME, 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) + fun restartTimer(context: Context, id: Int) { + context.sendBroadcast(TimerService.updateStateIntent(TimerService.TIMER_RESTART, 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 191f876a6..00f794f70 100644 --- a/app/src/main/java/com/bnyro/clock/util/Preferences.kt +++ b/app/src/main/java/com/bnyro/clock/util/Preferences.kt @@ -15,6 +15,9 @@ object Preferences { const val alarmPickerStyleKey = "alarm_use_scroll_picker" const val clockSortOrder = "clockSortOrder" const val savedTimersKey = "savedTimers" + const val timerBigStartButtonKey = "timerBigStartButton" + const val timerIncrementSecondsKey = "timerIncrementSeconds" + const val timerPickerBehaviourKey = "timerPickerBehaviour" const val snoozeTimeMinutesKey = "snoozeTimeMinutes" const val alarmTimeoutMinutesKey = "alarmTimeoutMinutes" const val customColorKey = "customColor" 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 8bd8867c0..25bc37a07 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 @@ -21,10 +21,10 @@ 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.NotificationManagerCompat @@ -37,6 +37,7 @@ import com.bnyro.clock.domain.model.TimerSettings import com.bnyro.clock.domain.model.WatchState import com.bnyro.clock.ui.MainActivity import com.bnyro.clock.util.NotificationHelper +import com.bnyro.clock.util.Preferences import java.util.Timer import java.util.TimerTask @@ -104,6 +105,9 @@ class TimerService : Service() { } } + private val incrementSeconds + get() = Preferences.instance.getInt(Preferences.timerIncrementSecondsKey, 60) + private val receiver = object : BroadcastReceiver() { @RequiresApi(Build.VERSION_CODES.N) override fun onReceive(context: Context, intent: Intent) { @@ -119,8 +123,8 @@ class TimerService : Service() { if (obj.state.value == WatchState.PAUSED) resume(obj) else pause(obj) } - ACTION_ADD_MINUTE -> { - obj.currentPosition.value += 60000 + ACTION_ADD_TIME -> { + obj.currentPosition.value += incrementSeconds * 1000 if (obj.state.value == WatchState.RUNNING) { cancelAlarm(obj) scheduleAlarm(obj) @@ -221,7 +225,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 ) @@ -239,7 +245,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 ) } @@ -299,39 +308,21 @@ class TimerService : Service() { private fun getNotification(timerObject: TimerObject) = NotificationCompat.Builder( this, NotificationHelper.TIMER_CHANNEL ).setContentTitle( - if (timerObject.state.value == WatchState.RUNNING) { - timerObject.label.value - } else { - getString(R.string.paused_timer_title, timerObject.label.value) - } + DateUtils.formatElapsedTime((timerObject.currentPosition.value / 1000).toLong()) ) - .setContentIntent(contentIntent) - .apply { + .setContentText( 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) - } else { - setContentText( - DateUtils.formatElapsedTime( - (timerObject.currentPosition.value / 1000).toLong() - ) - ) - } + timerObject.label.value } else { - setContentText( - DateUtils.formatElapsedTime( - (timerObject.currentPosition.value / 1000).toLong() - ) - ) - setShowWhen(false) + getString(R.string.paused_timer_title, timerObject.label.value) } - } + ) + .setContentIntent(contentIntent) + .setShowWhen(false) .addAction(pauseResumeAction(timerObject)) .addAction( if (timerObject.state.value == WatchState.RUNNING) { - addMinuteAction(timerObject) + addTimeAction(timerObject) } else { resetAction(timerObject) } @@ -350,10 +341,10 @@ class TimerService : Service() { timerObjects.forEach { if (it.state.value == WatchState.RUNNING) { - it.currentPosition.value = - (it.currentPosition.value - delta.toInt()).coerceAtLeast(0) + val before = it.currentPosition.value + it.currentPosition.value = (before - delta.toInt()).coerceAtLeast(0) - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + if (before / 1000 != it.currentPosition.value / 1000) { updateNotification(it) } } @@ -429,10 +420,7 @@ class TimerService : Service() { val notificationChannelId = NotificationHelper.TIMER_FINISHED_CHANNEL val finishedNotificationId = (Integer.MAX_VALUE / 3) + timerObject.id * 10 - val stopIntent = Intent(UPDATE_STATE_ACTION).apply { - putExtra(ACTION_EXTRA_KEY, ACTION_STOP) - putExtra(ID_EXTRA_KEY, timerObject.id) - } + val stopIntent = updateStateIntent(ACTION_STOP, timerObject.id) val stopPendingIntent = PendingIntent.getBroadcast( this, finishedNotificationId, @@ -443,10 +431,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, @@ -459,10 +444,7 @@ class TimerService : Service() { cancelAlarm(timerObject) - val deleteIntent = Intent(UPDATE_STATE_ACTION).apply { - putExtra(ACTION_EXTRA_KEY, ACTION_STOP) - putExtra(ID_EXTRA_KEY, timerObject.id) - } + val deleteIntent = updateStateIntent(ACTION_STOP, timerObject.id) val deletePendingIntent = PendingIntent.getBroadcast( this, finishedNotificationId + 1, deleteIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) @@ -488,33 +470,40 @@ class TimerService : Service() { 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 resetAction(timerObject: TimerObject) = getAction( - R.string.timer_reset, TIMER_RESTART, 7, timerObject.id + getString(R.string.timer_reset), TIMER_RESTART, 7, timerObject.id ) - private fun addMinuteAction(timerObject: TimerObject) = getAction( - R.string.add_one_minute, ACTION_ADD_MINUTE, 6, timerObject.id + private fun addTimeAction(timerObject: TimerObject) = getAction( + incrementSeconds.let { seconds -> + if (seconds == 60) { + getString(R.string.add_one_minute) + } else { + resources.getQuantityString(R.plurals.add_seconds, seconds, seconds) + } + }, + ACTION_ADD_TIME, + 6, + timerObject.id ) fun updateTimer(id: Int, settings: TimerSettings) { @@ -555,7 +544,14 @@ class TimerService : Service() { const val ACTION_STOP = "stop" private const val UPDATE_DELAY = 100 const val TIMER_RESTART = "timer_restart" - const val ACTION_ADD_MINUTE = "add_minute" + const val ACTION_ADD_TIME = "add_time" + const val UPDATE_STATE_SCHEME = "jaytimer" + + 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/values/strings.xml b/app/src/main/res/values/strings.xml index af95149f0..7321b984c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -91,6 +91,14 @@ Digital clock Vertical clock Picker style + Picker with a timer running + Keep open + Hide + Use big start button + Increment amount + Select increment amount + Show picker + Hide picker Picker style Scroll Numpad @@ -160,6 +168,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 From 577dc3d975d9be30657e4171068746538367a70c Mon Sep 17 00:00:00 2001 From: Ris Peng Date: Fri, 28 Aug 2026 14:26:18 +0800 Subject: [PATCH 10/44] coordinate timer picker visibility and stabilize wheel widths Hide the start button with its picker and animate the row height so restoring the picker remains visible. Reserve space for the widest wheel label even below three characters. Include the ringing duration of each advanced alarm recurrence in its card summary so multi-day runs are distinguishable. --- .../presentation/components/ScrollWheel.kt | 7 +- .../screens/alarm/components/AlarmCard.kt | 19 +++++ .../presentation/screens/timer/TimerScreen.kt | 79 ++++++++++--------- app/src/main/res/values/strings.xml | 1 + 4 files changed, 68 insertions(+), 38 deletions(-) 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 150c113ad..5cad528a5 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 @@ -31,6 +31,11 @@ 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( @@ -102,7 +107,7 @@ fun ScrollWheel( 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/screens/alarm/components/AlarmCard.kt b/app/src/main/java/com/bnyro/clock/presentation/screens/alarm/components/AlarmCard.kt index 67896e034..64247f08b 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 @@ -81,11 +81,30 @@ fun AlarmCard( ) 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( 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 8be114fbf..4cd154ffe 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,5 +1,10 @@ package com.bnyro.clock.presentation.screens.timer +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -9,7 +14,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons @@ -57,6 +61,7 @@ import com.bnyro.clock.presentation.screens.timer.components.TimerPickerSelector import com.bnyro.clock.presentation.screens.timer.model.TimerModel 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 @Composable @@ -82,10 +87,6 @@ fun TimerScreen( } } - // taking the picker away shortens what is above the list, so the list follows it up - val listState = rememberLazyListState() - LaunchedEffect(showPicker) { listState.scrollToItem(0) } - val selectedSavedTimerIds = remember { mutableStateListOf() } val isSelectionMode = selectedSavedTimerIds.isNotEmpty() var showDeletionDialog by remember { mutableStateOf(false) } @@ -122,43 +123,47 @@ fun TimerScreen( modifier = Modifier .fillMaxSize() .padding(paddingValues), - state = listState, contentPadding = PaddingValues(bottom = 16.dp) ) { - if (activeTimers.isEmpty() || showPicker) { - item(key = "picker") { - TimerPickerSelector( - modifier = Modifier.animateItem(ItemFade, ItemSlide, ItemFade), - pickerStyle = settingsModel.timerPickerStyle, - seconds = timerModel.timePickerSeconds, - onSecondsChanged = { timerModel.timePickerSeconds = it } - ) - } - } - item(key = "startButton") { - Row( - modifier = Modifier - .animateItem(ItemFade, ItemSlide, ItemFade) - .fillMaxWidth() - .padding(vertical = 16.dp), - horizontalArrangement = Arrangement.Center + item(key = "picker") { + AnimatedVisibility( + visible = activeTimers.isEmpty() || showPicker, + enter = fadeIn(ItemFade) + expandVertically(ListResize), + exit = shrinkVertically(ListResize) + fadeOut(ItemFade) ) { - val startTimer = { - timerModel.startTimer( - context, - TimerSettings(seconds = timerModel.timePickerSeconds) + Column { + TimerPickerSelector( + pickerStyle = settingsModel.timerPickerStyle, + seconds = timerModel.timePickerSeconds, + onSecondsChanged = { timerModel.timePickerSeconds = it } ) - } - if (settingsModel.timerBigStartButton) { - LargeFloatingActionButton(shape = CircleShape, onClick = startTimer) { - Icon(Icons.Default.PlayArrow, contentDescription = null) - } - } else { - FilledIconButton( - modifier = Modifier.size(48.dp), - onClick = startTimer + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + horizontalArrangement = Arrangement.Center ) { - Icon(Icons.Default.PlayArrow, contentDescription = null) + 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) + } + } } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7321b984c..a72add81b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -144,6 +144,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 From 4b87e20e8ec7cad305f203093c9abb09e3dc2a09 Mon Sep 17 00:00:00 2001 From: Ris Peng Date: Fri, 28 Aug 2026 14:47:59 +0800 Subject: [PATCH 11/44] place the timer picker above the scrolling list A collapsed picker lost its lazy-list anchor and expanded above the visible area. Place it above the list so its expansion moves the list down visibly. --- .../presentation/screens/timer/TimerScreen.kt | 225 +++++++++--------- 1 file changed, 113 insertions(+), 112 deletions(-) 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 4cd154ffe..803f59722 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 @@ -119,58 +119,100 @@ fun TimerScreen( } } ) { paddingValues -> - LazyColumn( + Column( modifier = Modifier .fillMaxSize() - .padding(paddingValues), - contentPadding = PaddingValues(bottom = 16.dp) + .padding(paddingValues) ) { - item(key = "picker") { - AnimatedVisibility( - visible = activeTimers.isEmpty() || showPicker, - enter = fadeIn(ItemFade) + expandVertically(ListResize), - exit = shrinkVertically(ListResize) + fadeOut(ItemFade) - ) { - Column { - TimerPickerSelector( - pickerStyle = settingsModel.timerPickerStyle, - seconds = timerModel.timePickerSeconds, - onSecondsChanged = { timerModel.timePickerSeconds = it } - ) - Row( + AnimatedVisibility( + visible = activeTimers.isEmpty() || showPicker, + enter = fadeIn(ItemFade) + expandVertically(ListResize), + exit = shrinkVertically(ListResize) + fadeOut(ItemFade) + ) { + Column { + 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) + } + } + } + } + } + LazyColumn( + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(bottom = 16.dp) + ) { + if (activeTimers.isNotEmpty()) { + item(key = "activeTimers") { + Column( modifier = Modifier - .fillMaxWidth() - .padding(vertical = 16.dp), - horizontalArrangement = Arrangement.Center + .animateItem(ItemFade, ItemSlide, ItemFade) + .padding(horizontal = 16.dp) ) { - 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 + 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 + } + ) ) { - Icon(Icons.Default.PlayArrow, contentDescription = null) + showPicker = !showPicker } } } } + items(activeTimers, key = { it.id }) { timer -> + TimerItem( + obj = timer, + timerModel = timerModel, + onEdit = { editedTimer = timer }, + modifier = Modifier.animateItem(ItemFade, ItemSlide, ItemFade) + ) + } } - } - if (activeTimers.isNotEmpty()) { - item(key = "activeTimers") { + item(key = "savedTimers") { Column( modifier = Modifier .animateItem(ItemFade, ItemSlide, ItemFade) @@ -181,86 +223,45 @@ fun TimerScreen( color = MaterialTheme.colorScheme.surfaceVariant ) SettingsCategory( - pluralStringResource(R.plurals.active_timers, activeTimers.size) + pluralStringResource(R.plurals.saved_timers, timerModel.savedTimers.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 - } - } - } - } - items(activeTimers, key = { it.id }) { timer -> - TimerItem( - obj = timer, - timerModel = timerModel, - onEdit = { editedTimer = timer }, - modifier = Modifier.animateItem(ItemFade, ItemSlide, ItemFade) - ) - } - } - - 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) + if (!isSelectionMode) { + ClickableIcon( + imageVector = Icons.Rounded.AddAlarm, + contentDescription = stringResource(R.string.add_saved_timer) + ) { + timerModel.addSavedTimer(timerModel.timePickerSeconds) + } } } } } - } - items(timerModel.savedTimers, key = { it.id }) { timer -> - val isSelected = selectedSavedTimerIds.contains(timer.id) + items(timerModel.savedTimers, key = { it.id }) { timer -> + val isSelected = selectedSavedTimerIds.contains(timer.id) - SavedTimerItem( - timer = timer, - isSelected = isSelected, - onStart = { - if (!isSelectionMode) timerModel.startTimer(context, timer) - }, - onClick = { - if (isSelectionMode) { - if (isSelected) { - selectedSavedTimerIds.remove(timer.id) + SavedTimerItem( + timer = timer, + isSelected = isSelected, + onStart = { + if (!isSelectionMode) timerModel.startTimer(context, timer) + }, + onClick = { + if (isSelectionMode) { + if (isSelected) { + selectedSavedTimerIds.remove(timer.id) + } else { + selectedSavedTimerIds.add(timer.id) + } } else { - selectedSavedTimerIds.add(timer.id) + editedSavedTimerId = timer.id } - } else { - editedSavedTimerId = timer.id - } - }, - onLongClick = { - if (!isSelectionMode) selectedSavedTimerIds.add(timer.id) - }, - modifier = Modifier.animateItem(ItemFade, ItemSlide, ItemFade) - ) + }, + onLongClick = { + if (!isSelectionMode) selectedSavedTimerIds.add(timer.id) + }, + modifier = Modifier.animateItem(ItemFade, ItemSlide, ItemFade) + ) + } } } } From c46b70f744f7ff496cdf390178f669e3e02f55e0 Mon Sep 17 00:00:00 2001 From: Ris Peng Date: Fri, 28 Aug 2026 15:02:45 +0800 Subject: [PATCH 12/44] hide the running timer picker by default Make automatic picker hiding the default and first option. Use the standard start button size unless the large button preference is enabled. --- .../com/bnyro/clock/domain/model/TimerPickerBehaviour.kt | 4 ++-- .../clock/presentation/screens/settings/SettingsScreen.kt | 4 ++-- .../presentation/screens/settings/model/SettingsModel.kt | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) 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 index 6db59e473..969df32c7 100644 --- a/app/src/main/java/com/bnyro/clock/domain/model/TimerPickerBehaviour.kt +++ b/app/src/main/java/com/bnyro/clock/domain/model/TimerPickerBehaviour.kt @@ -1,6 +1,6 @@ package com.bnyro.clock.domain.model enum class TimerPickerBehaviour { - KEEP_OPEN, - HIDE + HIDE, + KEEP_OPEN } 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 04a8e226a..4e48d0068 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 @@ -302,8 +302,8 @@ fun SettingsScreen( options = TimerPickerBehaviour.entries.map { stringResource( when (it) { - TimerPickerBehaviour.KEEP_OPEN -> R.string.picker_keep_open TimerPickerBehaviour.HIDE -> R.string.picker_hide + TimerPickerBehaviour.KEEP_OPEN -> R.string.picker_keep_open } ) }, @@ -317,7 +317,7 @@ fun SettingsScreen( SwitchPref( prefKey = Preferences.timerBigStartButtonKey, title = stringResource(R.string.timer_use_big_start), - defaultValue = true + defaultValue = false ) { settingsModel.timerBigStartButton = it } 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 e084171e8..dfaa92270 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 @@ -65,12 +65,12 @@ class SettingsModel : ViewModel() { TimerPickerBehaviour.valueOf( Preferences.instance.getString( Preferences.timerPickerBehaviourKey, - TimerPickerBehaviour.KEEP_OPEN.name - ) ?: TimerPickerBehaviour.KEEP_OPEN.name + TimerPickerBehaviour.HIDE.name + ) ?: TimerPickerBehaviour.HIDE.name ) ) var timerBigStartButton by mutableStateOf( - Preferences.instance.getBoolean(Preferences.timerBigStartButtonKey, true) + Preferences.instance.getBoolean(Preferences.timerBigStartButtonKey, false) ) var alarmPickerStyle by mutableStateOf( PickerStyle.valueOf( From 1003692a1fa31b194da48498a834c3fbed141908 Mon Sep 17 00:00:00 2001 From: Ris Peng Date: Fri, 28 Aug 2026 20:02:08 +0800 Subject: [PATCH 13/44] keep the timer picker scrollable and restore its list anchor Return the picker to the first list row so it scrolls with the page. Anchor the list to that row when restoring it and delay collapse until the first timer card finishes its entry animation. --- .../presentation/screens/timer/TimerScreen.kt | 236 +++++++++--------- .../java/com/bnyro/clock/ui/theme/Motion.kt | 3 +- 2 files changed, 125 insertions(+), 114 deletions(-) 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 803f59722..4eee2a26c 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 @@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close @@ -60,9 +61,11 @@ 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.ui.theme.ItemFade +import com.bnyro.clock.ui.theme.ItemFadeDurationMillis import com.bnyro.clock.ui.theme.ItemSlide import com.bnyro.clock.ui.theme.ListResize import com.bnyro.clock.util.extensions.KeepScreenOn +import kotlinx.coroutines.delay @Composable fun TimerScreen( @@ -83,10 +86,17 @@ fun TimerScreen( if (activeTimers.isNotEmpty() && settingsModel.timerPickerBehaviour == TimerPickerBehaviour.HIDE ) { + delay(ItemFadeDurationMillis.toLong()) showPicker = false } } + val pickerVisible = activeTimers.isEmpty() || showPicker + val timerPageState = rememberLazyListState() + LaunchedEffect(pickerVisible) { + if (pickerVisible) timerPageState.requestScrollToItem(0) + } + val selectedSavedTimerIds = remember { mutableStateListOf() } val isSelectionMode = selectedSavedTimerIds.isNotEmpty() var showDeletionDialog by remember { mutableStateOf(false) } @@ -119,100 +129,59 @@ fun TimerScreen( } } ) { paddingValues -> - Column( + LazyColumn( + state = timerPageState, modifier = Modifier .fillMaxSize() - .padding(paddingValues) + .padding(paddingValues), + contentPadding = PaddingValues(bottom = 16.dp) ) { - AnimatedVisibility( - visible = activeTimers.isEmpty() || showPicker, - enter = fadeIn(ItemFade) + expandVertically(ListResize), - exit = shrinkVertically(ListResize) + fadeOut(ItemFade) - ) { - Column { - 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) - } - } - } - } - } - LazyColumn( - modifier = Modifier.weight(1f), - contentPadding = PaddingValues(bottom = 16.dp) - ) { - if (activeTimers.isNotEmpty()) { - item(key = "activeTimers") { - Column( + item(key = "picker") { + AnimatedVisibility( + visible = pickerVisible, + enter = fadeIn(ItemFade) + expandVertically(ListResize), + exit = shrinkVertically(ListResize) + fadeOut(ItemFade) + ) { + Column { + TimerPickerSelector( + pickerStyle = settingsModel.timerPickerStyle, + seconds = timerModel.timePickerSeconds, + onSecondsChanged = { timerModel.timePickerSeconds = it } + ) + Row( modifier = Modifier - .animateItem(ItemFade, ItemSlide, ItemFade) - .padding(horizontal = 16.dp) + .fillMaxWidth() + .padding(vertical = 16.dp), + horizontalArrangement = Arrangement.Center ) { - 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 - } - ) + val startTimer = { + timerModel.startTimer( + context, + TimerSettings(seconds = timerModel.timePickerSeconds) + ) + } + if (settingsModel.timerBigStartButton) { + LargeFloatingActionButton( + shape = CircleShape, + onClick = startTimer ) { - showPicker = !showPicker + Icon(Icons.Default.PlayArrow, contentDescription = null) + } + } else { + FilledIconButton( + modifier = Modifier.size(48.dp), + onClick = startTimer + ) { + Icon(Icons.Default.PlayArrow, contentDescription = null) } } } } - items(activeTimers, key = { it.id }) { timer -> - TimerItem( - obj = timer, - timerModel = timerModel, - onEdit = { editedTimer = timer }, - modifier = Modifier.animateItem(ItemFade, ItemSlide, ItemFade) - ) - } } + } - item(key = "savedTimers") { + if (activeTimers.isNotEmpty()) { + item(key = "activeTimers") { Column( modifier = Modifier .animateItem(ItemFade, ItemSlide, ItemFade) @@ -223,46 +192,87 @@ fun TimerScreen( color = MaterialTheme.colorScheme.surfaceVariant ) SettingsCategory( - pluralStringResource(R.plurals.saved_timers, timerModel.savedTimers.size) + pluralStringResource(R.plurals.active_timers, activeTimers.size) ) { - if (!isSelectionMode) { - ClickableIcon( - imageVector = Icons.Rounded.AddAlarm, - contentDescription = stringResource(R.string.add_saved_timer) - ) { - timerModel.addSavedTimer(timerModel.timePickerSeconds) - } + 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 } } } } - items(timerModel.savedTimers, key = { it.id }) { timer -> - val isSelected = selectedSavedTimerIds.contains(timer.id) - - SavedTimerItem( - timer = timer, - isSelected = isSelected, - onStart = { - if (!isSelectionMode) timerModel.startTimer(context, timer) - }, - onClick = { - if (isSelectionMode) { - if (isSelected) { - selectedSavedTimerIds.remove(timer.id) - } else { - selectedSavedTimerIds.add(timer.id) - } - } else { - editedSavedTimerId = timer.id - } - }, - onLongClick = { - if (!isSelectionMode) selectedSavedTimerIds.add(timer.id) - }, + items(activeTimers, key = { it.id }) { timer -> + TimerItem( + obj = timer, + timerModel = timerModel, + onEdit = { editedTimer = timer }, modifier = Modifier.animateItem(ItemFade, ItemSlide, ItemFade) ) } } + + 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) + } + } + } + } + } + items(timerModel.savedTimers, key = { it.id }) { timer -> + val isSelected = selectedSavedTimerIds.contains(timer.id) + + SavedTimerItem( + timer = timer, + isSelected = isSelected, + onStart = { + if (!isSelectionMode) timerModel.startTimer(context, timer) + }, + onClick = { + if (isSelectionMode) { + if (isSelected) { + selectedSavedTimerIds.remove(timer.id) + } else { + selectedSavedTimerIds.add(timer.id) + } + } else { + editedSavedTimerId = timer.id + } + }, + onLongClick = { + if (!isSelectionMode) selectedSavedTimerIds.add(timer.id) + }, + modifier = Modifier.animateItem(ItemFade, ItemSlide, ItemFade) + ) + } } } 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 index 4d025519a..a546af547 100644 --- a/app/src/main/java/com/bnyro/clock/ui/theme/Motion.kt +++ b/app/src/main/java/com/bnyro/clock/ui/theme/Motion.kt @@ -9,8 +9,9 @@ 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 = 120, easing = FastOutSlowInEasing) + 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. From bf694bad80863becbedc4f060b0bd7a87df3e7c9 Mon Sep 17 00:00:00 2001 From: Ris Peng Date: Fri, 28 Aug 2026 20:19:18 +0800 Subject: [PATCH 14/44] unify timer picker visibility and list animations Lazy lists drop rows that shrink to zero, preventing the picker from expanding smoothly. Use the same entry and exit animations as timer cards and scroll to the top when the picker returns. Use one visibility state for preferences and running timers so delayed collapse also applies when the first timer starts automatically. --- .../presentation/screens/timer/TimerScreen.kt | 32 +++++++------------ 1 file changed, 12 insertions(+), 20 deletions(-) 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 4eee2a26c..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,10 +1,5 @@ package com.bnyro.clock.presentation.screens.timer -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -63,7 +58,6 @@ import com.bnyro.clock.presentation.screens.timer.model.TimerModel 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.ui.theme.ListResize import com.bnyro.clock.util.extensions.KeepScreenOn import kotlinx.coroutines.delay @@ -80,21 +74,23 @@ fun TimerScreen( // 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(settingsModel.timerPickerBehaviour == TimerPickerBehaviour.KEEP_OPEN) + mutableStateOf( + activeTimers.isEmpty() || + settingsModel.timerPickerBehaviour == TimerPickerBehaviour.KEEP_OPEN + ) } LaunchedEffect(activeTimers.isEmpty(), settingsModel.timerPickerBehaviour) { - if (activeTimers.isNotEmpty() && - settingsModel.timerPickerBehaviour == TimerPickerBehaviour.HIDE - ) { + if (activeTimers.isEmpty()) { + showPicker = true + } else if (settingsModel.timerPickerBehaviour == TimerPickerBehaviour.HIDE) { delay(ItemFadeDurationMillis.toLong()) showPicker = false } } - val pickerVisible = activeTimers.isEmpty() || showPicker val timerPageState = rememberLazyListState() - LaunchedEffect(pickerVisible) { - if (pickerVisible) timerPageState.requestScrollToItem(0) + LaunchedEffect(showPicker) { + if (showPicker) timerPageState.animateScrollToItem(0) } val selectedSavedTimerIds = remember { mutableStateListOf() } @@ -136,13 +132,9 @@ fun TimerScreen( .padding(paddingValues), contentPadding = PaddingValues(bottom = 16.dp) ) { - item(key = "picker") { - AnimatedVisibility( - visible = pickerVisible, - enter = fadeIn(ItemFade) + expandVertically(ListResize), - exit = shrinkVertically(ListResize) + fadeOut(ItemFade) - ) { - Column { + if (showPicker) { + item(key = "picker") { + Column(modifier = Modifier.animateItem(ItemFade, ItemSlide, ItemFade)) { TimerPickerSelector( pickerStyle = settingsModel.timerPickerStyle, seconds = timerModel.timePickerSeconds, From a1139b6e85ae8d0e341c6410c6c91d2278434aa4 Mon Sep 17 00:00:00 2001 From: Ris Peng Date: Sat, 29 Aug 2026 07:47:04 +0800 Subject: [PATCH 15/44] use shorter default saved timer durations --- .../main/java/com/bnyro/clock/domain/model/TimerSettings.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index cd1ebca7d..889a0644a 100644 --- a/app/src/main/java/com/bnyro/clock/domain/model/TimerSettings.kt +++ b/app/src/main/java/com/bnyro/clock/domain/model/TimerSettings.kt @@ -31,10 +31,10 @@ data class TimerSettings( ) : Parcelable { companion object { private val exampleTimers = listOf( + 60 * 5, 60 * 10, 60 * 15, - 60 * 30, - 60 * 60 + 60 * 30 ).mapIndexed { index, seconds -> TimerSettings(id = index + 1, seconds = seconds) } fun setSavedTimers(timers: List) { From 18b67e43951a65f107cbede85da7f4e5b453e409 Mon Sep 17 00:00:00 2001 From: Ris Peng Date: Sat, 29 Aug 2026 07:47:47 +0800 Subject: [PATCH 16/44] round remaining timer seconds up Flooring the remaining duration displayed zero throughout the final second. Share the rounded-up count between cards, notification titles, and refresh scheduling so zero coincides with expiry. Use a consistent clock for the initial tick and subsequent readings to avoid an invalid initial delta if a timer is queued before the first tick. --- .../java/com/bnyro/clock/domain/model/TimerObject.kt | 4 ++++ .../screens/timer/components/TimerItem.kt | 6 +++--- .../com/bnyro/clock/util/services/TimerService.kt | 12 ++++++------ 3 files changed, 13 insertions(+), 9 deletions(-) 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 c8ed522c2..8b0b5ad7b 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 @@ -2,6 +2,7 @@ package com.bnyro.clock.domain.model import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf +import kotlin.math.ceil data class TimerObject( var id: Int = 0, @@ -16,6 +17,9 @@ data class TimerObject( var vibrationPattern: List = List(5) { 1000 }, var vibrationPatternName: String = "Default" ) { + val secondsLeft: Int + get() = ceil(currentPosition.value / 1000.0).toInt() + val settings: TimerSettings get() = TimerSettings( seconds = initialPosition.value / 1000, 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 a37a0b2f4..ad7679f73 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 @@ -53,9 +53,9 @@ fun TimerItem( ) { 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 + val hours = obj.secondsLeft / 3600 + val minutes = (obj.secondsLeft % 3600) / 60 + val seconds = obj.secondsLeft % 60 val cardShape = RoundedCornerShape(20.dp) ElevatedCard( 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 25bc37a07..9c5d89f6f 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 @@ -19,7 +19,6 @@ import android.os.Build import android.os.Handler 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 @@ -49,7 +48,7 @@ class TimerService : Service() { private var mediaPlayer: MediaPlayer? = null private var isPlaying = false - private var oldnow = SystemClock.elapsedRealtime() + private var oldnow = System.currentTimeMillis() private val vibrator: Vibrator by lazy { getSystemService(Context.VIBRATOR_SERVICE) as Vibrator } @@ -308,7 +307,7 @@ class TimerService : Service() { private fun getNotification(timerObject: TimerObject) = NotificationCompat.Builder( this, NotificationHelper.TIMER_CHANNEL ).setContentTitle( - DateUtils.formatElapsedTime((timerObject.currentPosition.value / 1000).toLong()) + DateUtils.formatElapsedTime(timerObject.secondsLeft.toLong()) ) .setContentText( if (timerObject.state.value == WatchState.RUNNING) { @@ -341,10 +340,11 @@ class TimerService : Service() { timerObjects.forEach { if (it.state.value == WatchState.RUNNING) { - val before = it.currentPosition.value - it.currentPosition.value = (before - delta.toInt()).coerceAtLeast(0) + val before = it.secondsLeft + it.currentPosition.value = + (it.currentPosition.value - delta.toInt()).coerceAtLeast(0) - if (before / 1000 != it.currentPosition.value / 1000) { + if (before != it.secondsLeft) { updateNotification(it) } } From 951ea647df9421985674cde9e5d9a5420e417abb Mon Sep 17 00:00:00 2001 From: Ris Peng Date: Sat, 29 Aug 2026 07:47:55 +0800 Subject: [PATCH 17/44] allow heads-up notifications for finished timers Marking the notification silent prevented heads-up display despite its high-importance channel. Remove that flag and the ineffective insistent flag; the timer service remains responsible for ringtone playback until dismissal. --- .../main/java/com/bnyro/clock/util/services/TimerService.kt | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) 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 9c5d89f6f..8356c54b9 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 @@ -451,7 +451,6 @@ class TimerService : Service() { val notification = NotificationCompat.Builder(this, notificationChannelId) .setSmallIcon(R.drawable.ic_timer) - .setSilent(true) .setContentTitle(getString(R.string.finished_named_timer, timerObject.label.value)) .setContentIntent(contentIntent) .setCategory(NotificationCompat.CATEGORY_ALARM) @@ -460,9 +459,7 @@ class TimerService : Service() { .setOngoing(false) .addAction(stopAction) .addAction(restartAction) - .build().apply { - flags = flags or NotificationCompat.FLAG_INSISTENT - } + .build() NotificationManagerCompat.from(this).notify(finishedNotificationId, notification) } From 8a802b60cd5c65a19fe58910a2286e14c2870704 Mon Sep 17 00:00:00 2001 From: Ris Peng Date: Sat, 29 Aug 2026 12:39:16 +0800 Subject: [PATCH 18/44] suppress alarm popups while the ringing screen is visible The service could show both the ringing activity and its heads-up notification. Track activity visibility and post silently without a full-screen intent while it is visible, retaining the foreground service entry. Restore alerting when the activity leaves the screen. --- .../screens/alarm/AlarmActivity.kt | 18 ++++++++++++++++++ .../bnyro/clock/util/services/AlarmService.kt | 17 +++++++++++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) 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..51783a874 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 @@ -180,6 +180,24 @@ class AlarmActivity : ComponentActivity() { super.onDestroy() } + override fun onStart() { + super.onStart() + sendBroadcast( + Intent(AlarmService.ALARM_INTENT_ACTION) + .putExtra(AlarmService.ACTION_EXTRA_KEY, AlarmService.ALERT_SHOWN_ACTION) + .setPackage(packageName) + ) + } + + override fun onStop() { + sendBroadcast( + Intent(AlarmService.ALARM_INTENT_ACTION) + .putExtra(AlarmService.ACTION_EXTRA_KEY, AlarmService.ALERT_HIDDEN_ACTION) + .setPackage(packageName) + ) + super.onStop() + } + override fun onResume() { super.onResume() sensorManager.registerListener( 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..6cff59b46 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 @@ -45,6 +45,7 @@ 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 @@ -63,7 +64,7 @@ class AlarmService : Service() { 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 +76,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) + ) + } + } } } } @@ -301,7 +311,8 @@ class AlarmService : Service() { 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,6 +343,8 @@ 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 From 91b74dc1380b3593e9344c397d95a4094a92a58c Mon Sep 17 00:00:00 2001 From: Ris Peng Date: Sat, 29 Aug 2026 20:23:44 +0800 Subject: [PATCH 19/44] silence notification channels used by ringing services Alarm and timer services already play the selected ringtone, so channel sounds added an unwanted chime on each notification update. Create silent channels under new identifiers because existing channel sound settings cannot be changed, and remove obsolete channels. --- .../main/java/com/bnyro/clock/util/NotificationHelper.kt | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) 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..50a5122e0 100644 --- a/app/src/main/java/com/bnyro/clock/util/NotificationHelper.kt +++ b/app/src/main/java/com/bnyro/clock/util/NotificationHelper.kt @@ -13,11 +13,11 @@ import com.bnyro.clock.util.receivers.DeleteNotificationChannelReceiver 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_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) @@ -78,12 +78,14 @@ object NotificationHelper { 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 +96,6 @@ object NotificationHelper { ) nManager.createNotificationChannelsCompat(channels) + nManager.deleteUnlistedNotificationChannels(channels.map { it.id }) } } From 44ef5b980d8a2dfaf1c750d707a037934736ae7a Mon Sep 17 00:00:00 2001 From: Ris Peng Date: Sat, 29 Aug 2026 20:23:44 +0800 Subject: [PATCH 20/44] start the default vibration pattern immediately The default pattern inserted a one-second delay before each buzz sequence. Begin with zero delay for newly created alarms and timers. Preserve stored patterns and the existing database column default in this change. --- app/src/main/assets/vibration_patterns.json | 2 +- app/src/main/java/com/bnyro/clock/domain/model/Alarm.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/assets/vibration_patterns.json b/app/src/main/assets/vibration_patterns.json index 18d764159..adb2aacdf 100644 --- a/app/src/main/assets/vibration_patterns.json +++ b/app/src/main/assets/vibration_patterns.json @@ -1,6 +1,6 @@ { "Default": [ - 1000, + 0, 1000, 1000, 1000, 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..b88891b9e 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 = "1000,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(), From 4297c854f6e39180d707dc2b18e07a9f837d26d7 Mon Sep 17 00:00:00 2001 From: Ris Peng Date: Sat, 29 Aug 2026 20:24:23 +0800 Subject: [PATCH 21/44] start new timer vibration without an initial delay The default pattern delayed vibration for a second after expiry. Start it immediately for newly created timers while leaving saved patterns unchanged. --- app/src/main/java/com/bnyro/clock/domain/model/TimerObject.kt | 2 +- app/src/main/java/com/bnyro/clock/domain/model/TimerSettings.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 8b0b5ad7b..dd926a3cb 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 @@ -14,7 +14,7 @@ data class TimerObject( var soundUri: String? = null, var soundEnabled: Boolean = true, var vibrate: Boolean = true, - var vibrationPattern: List = List(5) { 1000 }, + var vibrationPattern: List = listOf(0, 1000, 1000, 1000, 1000), var vibrationPatternName: String = "Default" ) { val secondsLeft: Int 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 index 889a0644a..6f3b8cff0 100644 --- a/app/src/main/java/com/bnyro/clock/domain/model/TimerSettings.kt +++ b/app/src/main/java/com/bnyro/clock/domain/model/TimerSettings.kt @@ -26,7 +26,7 @@ data class TimerSettings( val soundUri: String? = null, val soundEnabled: Boolean = true, val vibrate: Boolean = true, - val vibrationPattern: List = List(5) { 1000 }, + val vibrationPattern: List = listOf(0, 1000, 1000, 1000, 1000), val vibrationPatternName: String = "Default" ) : Parcelable { companion object { From 1927196096720864a98c408c44537d6f7889e855 Mon Sep 17 00:00:00 2001 From: Ris Peng Date: Sat, 29 Aug 2026 21:11:55 +0800 Subject: [PATCH 22/44] color ringing alarm notifications with the app accent Enable colorization for the foreground service notification and use the theme accent already used by widgets. --- .../main/java/com/bnyro/clock/util/services/AlarmService.kt | 4 ++++ 1 file changed, 4 insertions(+) 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 6cff59b46..513883b17 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 @@ -35,6 +35,8 @@ 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.widgets.TextColor +import com.bnyro.clock.util.widgets.getColorValue import kotlinx.coroutines.runBlocking import java.util.Timer import java.util.TimerTask @@ -308,6 +310,8 @@ class AlarmService : Service() { } ?: context.getString(R.string.ringing_alarm, formattedTime) ) setAutoCancel(true) + setColorized(true) + setColor(TextColor.Primary.getColorValue(context)) priority = NotificationCompat.PRIORITY_MAX foregroundServiceBehavior = FOREGROUND_SERVICE_IMMEDIATE setCategory(NotificationCompat.CATEGORY_ALARM) From cfabbf218dde0cc0fb4ca469bd119dc6e497d787 Mon Sep 17 00:00:00 2001 From: Ris Peng Date: Sat, 29 Aug 2026 21:13:00 +0800 Subject: [PATCH 23/44] request live updates for running timer notifications Request Android 16 notification promotion so the system may show the remaining time in a status bar chip. Older versions ignore the request. Use the same countdown value for the chip and notification title. --- .../bnyro/clock/util/services/TimerService.kt | 53 ++++++++++--------- 1 file changed, 29 insertions(+), 24 deletions(-) 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 8356c54b9..1d9012462 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 @@ -304,30 +305,34 @@ class TimerService : Service() { return START_STICKY } - private fun getNotification(timerObject: TimerObject) = NotificationCompat.Builder( - this, NotificationHelper.TIMER_CHANNEL - ).setContentTitle( - DateUtils.formatElapsedTime(timerObject.secondsLeft.toLong()) - ) - .setContentText( - if (timerObject.state.value == WatchState.RUNNING) { - timerObject.label.value - } else { - getString(R.string.paused_timer_title, timerObject.label.value) - } - ) - .setContentIntent(contentIntent) - .setShowWhen(false) - .addAction(pauseResumeAction(timerObject)) - .addAction( - if (timerObject.state.value == WatchState.RUNNING) { - addTimeAction(timerObject) - } else { - resetAction(timerObject) - } - ) - .addAction(stopAction(timerObject)) - .setSmallIcon(R.drawable.ic_timer).setOngoing(true).build() + 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) { + timerObject.label.value + } else { + getString(R.string.paused_timer_title, timerObject.label.value) + } + ) + .setContentIntent(contentIntent) + .setShowWhen(false) + .addAction(pauseResumeAction(timerObject)) + .addAction( + if (timerObject.state.value == WatchState.RUNNING) { + addTimeAction(timerObject) + } else { + resetAction(timerObject) + } + ) + .addAction(stopAction(timerObject)) + .setSmallIcon(R.drawable.ic_timer).setOngoing(true).build() + } fun invokeChangeListener() { onChangeTimers.invoke(timerObjects.toTypedArray()) From 52efeb32af39ecdbbba842c39c85495ea364b2b1 Mon Sep 17 00:00:00 2001 From: Ris Peng Date: Sat, 29 Aug 2026 21:17:28 +0800 Subject: [PATCH 24/44] raise timer notification importance for live updates The running timer channel's minimum importance prevented status bar promotion. Create a replacement channel at the required higher importance, with sound disabled and alerting limited to the initial notification. --- app/src/main/java/com/bnyro/clock/util/NotificationHelper.kt | 5 +++-- .../main/java/com/bnyro/clock/util/services/TimerService.kt | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) 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..b16209aff 100644 --- a/app/src/main/java/com/bnyro/clock/util/NotificationHelper.kt +++ b/app/src/main/java/com/bnyro/clock/util/NotificationHelper.kt @@ -12,7 +12,7 @@ import com.bnyro.clock.util.receivers.DeleteNotificationChannelReceiver object NotificationHelper { const val STOPWATCH_CHANNEL = "stopwatch" - const val TIMER_CHANNEL = "timer" + const val TIMER_CHANNEL = "timer_ongoing" const val TIMER_FINISHED_CHANNEL = "timer_finished" const val ALARM_CHANNEL = "alarm" const val MISSED_ALARM_CHANNEL = "missed_alarm" @@ -69,9 +69,10 @@ 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, 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 1d9012462..bb899916f 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 @@ -322,6 +322,7 @@ class TimerService : Service() { ) .setContentIntent(contentIntent) .setShowWhen(false) + .setOnlyAlertOnce(true) .addAction(pauseResumeAction(timerObject)) .addAction( if (timerObject.state.value == WatchState.RUNNING) { From e1b85833599ca0cb62c9e0409dd80053bf6cc355 Mon Sep 17 00:00:00 2001 From: Ris Peng Date: Sun, 30 Aug 2026 06:15:05 +0800 Subject: [PATCH 25/44] use a darker accent for ringing alarm notifications --- app/src/main/java/com/bnyro/clock/util/services/AlarmService.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 513883b17..4ad4685e1 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 @@ -311,7 +311,7 @@ class AlarmService : Service() { ) setAutoCancel(true) setColorized(true) - setColor(TextColor.Primary.getColorValue(context)) + setColor(TextColor.PrimaryDark.getColorValue(context)) priority = NotificationCompat.PRIORITY_MAX foregroundServiceBehavior = FOREGROUND_SERVICE_IMMEDIATE setCategory(NotificationCompat.CATEGORY_ALARM) From 9e73b55e1bd0bc960253924f403287c8bd14b8dc Mon Sep 17 00:00:00 2001 From: Ris Peng Date: Mon, 31 Aug 2026 02:13:12 +0800 Subject: [PATCH 26/44] share the ringing activity behavior and layout Extract lock-screen visibility, display wake handling, face-down actions, and volume key behavior so other ringing events can reuse them. Keep alarm-specific dismissal and snooze behavior in the alarm activity. Share the dark layout and landscape arrangement while accepting the displayed time, label, and ringing icon from each activity. --- .../screens/alarm/AlarmActivity.kt | 142 +-------------- .../screens/alarm/AlarmAlertScreen.kt | 131 +------------- .../screens/ringing/RingingActivity.kt | 163 ++++++++++++++++++ .../screens/ringing/RingingAlert.kt | 155 +++++++++++++++++ .../bnyro/clock/util/services/AlarmService.kt | 3 +- 5 files changed, 333 insertions(+), 261 deletions(-) create mode 100644 app/src/main/java/com/bnyro/clock/presentation/screens/ringing/RingingActivity.kt create mode 100644 app/src/main/java/com/bnyro/clock/presentation/screens/ringing/RingingAlert.kt 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..62717339b 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,108 +1,27 @@ 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 - - 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() - } - } - } + override val closeAction = ALARM_ALERT_CLOSE_ACTION - 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 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, @@ -115,7 +34,7 @@ class AlarmActivity : ComponentActivity() { handleIntent(intent) } - private fun dismiss() { + override fun dismiss() { stopService( Intent( this@AlarmActivity.applicationContext, @@ -125,7 +44,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 +57,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 +70,7 @@ 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 onPause() { - super.onPause() - sensorManager.unregisterListener(sensorEventListener) - } - 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..2a481257d 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,21 @@ 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 @Composable fun AlarmAlertScreen( @@ -67,52 +42,8 @@ fun AlarmAlertScreen( snoozeEnabled: Boolean, snoozeTime: Int ) { - 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, snoozeTime, snoozeEnabled, onSnooze, onDismiss) } } @@ -124,23 +55,7 @@ private fun AlarmControls( 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 - ) - label?.let { - Text(text = it, style = MaterialTheme.typography.headlineMedium) - } + RingingTitle(label) Column( Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally @@ -202,42 +117,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, 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..b7b1d7481 --- /dev/null +++ b/app/src/main/java/com/bnyro/clock/presentation/screens/ringing/RingingActivity.kt @@ -0,0 +1,163 @@ +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 + + 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) as Sensor + } + 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( + Preferences.volumeButtonActionKey, + VolumeButtonAction.SNOOZE.name + ) ?: VolumeButtonAction.SNOOZE.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() + sensorManager.registerListener( + sensorEventListener, + gravitySensor, + 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..93ca8db17 --- /dev/null +++ b/app/src/main/java/com/bnyro/clock/presentation/screens/ringing/RingingAlert.kt @@ -0,0 +1,155 @@ +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 + +/** + * 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 it is now over the name of whatever is ringing, which a reader woken by it reads first. + */ +@Composable +fun RingingTitle(label: String?) { + 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 + ) + 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/util/services/AlarmService.kt b/app/src/main/java/com/bnyro/clock/util/services/AlarmService.kt index aa0fd39a1..3588852d8 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 @@ -30,6 +30,7 @@ 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 @@ -232,7 +233,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) From 57f423daf32c5703c17befb0c5200b03bb36bd28 Mon Sep 17 00:00:00 2001 From: Ris Peng Date: Mon, 31 Aug 2026 02:13:24 +0800 Subject: [PATCH 27/44] show full-screen alerts for finished timers Finished timers previously relied on notifications alone. Use the shared ringing screen with dismiss, increment, and restart actions, and provide a timer setting to retain notification-only behavior. Restart an expired timer when time is added so the added duration counts down. Cancel its scheduled alarm at expiry rather than during notification posting, preventing later notification updates from cancelling a restarted timer. --- app/src/main/AndroidManifest.xml | 9 ++ .../screens/settings/SettingsScreen.kt | 6 + .../screens/timer/TimerAlertActivity.kt | 75 ++++++++++ .../screens/timer/TimerAlertScreen.kt | 138 ++++++++++++++++++ .../java/com/bnyro/clock/util/Preferences.kt | 1 + .../bnyro/clock/util/services/TimerService.kt | 116 +++++++++++++-- app/src/main/res/values/strings.xml | 1 + 7 files changed, 331 insertions(+), 15 deletions(-) create mode 100644 app/src/main/java/com/bnyro/clock/presentation/screens/timer/TimerAlertActivity.kt create mode 100644 app/src/main/java/com/bnyro/clock/presentation/screens/timer/TimerAlertScreen.kt 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 @@ + + (null) + + override val closeAction = TimerService.TIMER_ALERT_CLOSE_ACTION + + 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, + incrementSeconds = Preferences.instance.getInt( + Preferences.timerIncrementSecondsKey, + 60 + ) + ) + } + } + + 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) { + handleIntent(intent) + super.onNewIntent(intent) + } + + private fun handleIntent(intent: Intent) { + timerId = intent.getIntExtra(TimerService.ID_EXTRA_KEY, 0) + label = intent.getStringExtra(TimerService.LABEL_EXTRA_KEY) + } +} 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..3483eee2d --- /dev/null +++ b/app/src/main/java/com/bnyro/clock/presentation/screens/timer/TimerAlertScreen.kt @@ -0,0 +1,138 @@ +package com.bnyro.clock.presentation.screens.timer + +import android.content.res.Configuration +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.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 + +@Composable +fun TimerAlertScreen( + onDismiss: () -> Unit, + onSnooze: () -> Unit, + onReset: () -> Unit, + label: String? = null, + incrementSeconds: Int +) { + RingingAlert(rememberVectorPainter(Icons.Rounded.Timer)) { + TimerAlertControls(label, incrementSeconds, onDismiss, onSnooze, onReset) + } +} + +@Composable +private fun TimerAlertControls( + label: String?, + incrementSeconds: Int, + onDismiss: () -> Unit, + onSnooze: () -> Unit, + onReset: () -> Unit +) { + RingingTitle(label) + 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", + incrementSeconds = 60 + ) +} 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 00f794f70..12c4f2ffe 100644 --- a/app/src/main/java/com/bnyro/clock/util/Preferences.kt +++ b/app/src/main/java/com/bnyro/clock/util/Preferences.kt @@ -18,6 +18,7 @@ object Preferences { const val timerBigStartButtonKey = "timerBigStartButton" const val timerIncrementSecondsKey = "timerIncrementSeconds" const val timerPickerBehaviourKey = "timerPickerBehaviour" + const val timerFullScreenAlertKey = "timerFullScreenAlert" const val snoozeTimeMinutesKey = "snoozeTimeMinutes" const val alarmTimeoutMinutesKey = "alarmTimeoutMinutes" const val customColorKey = "customColor" 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 bb899916f..625559232 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 @@ -35,6 +35,8 @@ 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 @@ -60,6 +62,7 @@ class TimerService : Service() { var timerObjects = mutableListOf() private var wakeLock: PowerManager.WakeLock? = null + private var alertedTimerId: Int? = null @SuppressLint("ServiceCast", "ScheduleExactAlarm") private fun scheduleAlarm(timerObject: TimerObject) { @@ -108,13 +111,16 @@ class TimerService : Service() { private val incrementSeconds get() = Preferences.instance.getInt(Preferences.timerIncrementSecondsKey, 60) + private val fullScreenAlertEnabled + get() = Preferences.instance.getBoolean(Preferences.timerFullScreenAlertKey, true) + 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) } @@ -123,17 +129,42 @@ class TimerService : Service() { if (obj.state.value == WatchState.PAUSED) resume(obj) else pause(obj) } + ACTION_ALERT_SHOWN, ACTION_ALERT_HIDDEN -> { + alertedTimerId = obj.id.takeIf { action == ACTION_ALERT_SHOWN } + if (obj.currentPosition.value == 0) showFinishedNotification(obj) + } + 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) { + stopAudio() + closeAlert(obj) + oldnow = System.currentTimeMillis() + obj.state.value = WatchState.RUNNING + } + obj.currentPosition.value += incrementSeconds * 1000 + if (obj.state.value == WatchState.RUNNING) { cancelAlarm(obj) scheduleAlarm(obj) + if (finished) acquireWakeLock() + } + + if (finished) { + val notificationManager = NotificationManagerCompat.from(context) + notificationManager.cancel(finishedNotificationId(obj)) + invokeChangeListener() } + updateNotification(obj) } TIMER_RESTART -> { stopAudio() + closeAlert(obj) oldnow = System.currentTimeMillis() @@ -147,9 +178,8 @@ class TimerService : Service() { 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) invokeChangeListener() @@ -269,6 +299,7 @@ class TimerService : Service() { } play(obj) + cancelAlarm(obj) val notificationManager = NotificationManagerCompat.from(this) notificationManager.cancel(obj.id) @@ -278,6 +309,8 @@ class TimerService : Service() { } showFinishedNotification(obj) + if (fullScreenAlertEnabled) startActivity(alertIntent(obj)) + obj.currentPosition.value = 0 obj.state.value = WatchState.PAUSED @@ -405,11 +438,11 @@ class TimerService : Service() { releaseWakeLock() } + closeAlert(timerObject) invokeChangeListener() 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) @@ -417,6 +450,29 @@ class TimerService : Service() { } } + 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) + + 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) { if (ActivityCompat.checkSelfPermission( this, Manifest.permission.POST_NOTIFICATIONS @@ -424,7 +480,8 @@ class TimerService : Service() { ) return val notificationChannelId = NotificationHelper.TIMER_FINISHED_CHANNEL - val finishedNotificationId = (Integer.MAX_VALUE / 3) + timerObject.id * 10 + val finishedNotificationId = finishedNotificationId(timerObject) + val alertShowing = alertedTimerId == timerObject.id val stopIntent = updateStateIntent(ACTION_STOP, timerObject.id) val stopPendingIntent = PendingIntent.getBroadcast( @@ -445,10 +502,26 @@ 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(), snoozePendingIntent + ).build() + + 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( @@ -463,7 +536,14 @@ class TimerService : Service() { .setPriority(NotificationCompat.PRIORITY_MAX) .setDeleteIntent(deletePendingIntent) .setOngoing(false) + .setSilent(alertShowing) + .apply { + if (fullScreenAlertEnabled && !alertShowing) { + setFullScreenIntent(alertPendingIntent, true) + } + } .addAction(stopAction) + .addAction(snoozeAction) .addAction(restartAction) .build() @@ -497,18 +577,20 @@ class TimerService : Service() { ) private fun addTimeAction(timerObject: TimerObject) = getAction( - incrementSeconds.let { seconds -> - if (seconds == 60) { - getString(R.string.add_one_minute) - } else { - resources.getQuantityString(R.plurals.add_seconds, seconds, seconds) - } - }, + addTimeLabel(), ACTION_ADD_TIME, 6, timerObject.id ) + private fun addTimeLabel() = incrementSeconds.let { seconds -> + if (seconds == 60) { + getString(R.string.add_one_minute) + } else { + resources.getQuantityString(R.plurals.add_seconds, seconds, seconds) + } + } + fun updateTimer(id: Int, settings: TimerSettings) { timerObjects.firstOrNull { it.id == id }?.let { it.label.value = settings.label @@ -549,6 +631,10 @@ class TimerService : Service() { const val TIMER_RESTART = "timer_restart" const val ACTION_ADD_TIME = "add_time" const val UPDATE_STATE_SCHEME = "jaytimer" + const val ACTION_ALERT_SHOWN = "alert_shown" + const val ACTION_ALERT_HIDDEN = "alert_hidden" + const val LABEL_EXTRA_KEY = "label" + const val TIMER_ALERT_CLOSE_ACTION = "com.bnyro.clock.TIMER_ALERT_CLOSE_ACTION" fun updateStateIntent(action: String, objectId: Int): Intent = Intent(UPDATE_STATE_ACTION) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a72add81b..248c5f8b7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -94,6 +94,7 @@ Picker with a timer running Keep open Hide + Full-screen timer alert Use big start button Increment amount Select increment amount From a311ed26508cbd01751944749d72f194c1b21c68 Mon Sep 17 00:00:00 2001 From: Ris Peng Date: Tue, 1 Sep 2026 11:04:05 +0800 Subject: [PATCH 28/44] track timer overrun and configure ringing behavior Continue counting elapsed time after expiry, add ringing timeout and volume ramp settings, and let a later timer replace the current ringtone while retaining the earlier timer's ringing duration. --- .../screens/settings/SettingsScreen.kt | 110 ++++++++++ .../screens/timer/TimerAlertActivity.kt | 11 +- .../screens/timer/TimerAlertScreen.kt | 22 +- .../java/com/bnyro/clock/util/Preferences.kt | 3 + .../java/com/bnyro/clock/util/VolumeRamp.kt | 43 ++++ .../bnyro/clock/util/services/AlarmService.kt | 28 +-- .../bnyro/clock/util/services/TimerService.kt | 190 +++++++++++++++--- app/src/main/res/values/strings.xml | 5 + 8 files changed, 361 insertions(+), 51 deletions(-) create mode 100644 app/src/main/java/com/bnyro/clock/util/VolumeRamp.kt 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 cb1ff130a..5a130b1c9 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,6 +10,7 @@ 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 @@ -56,6 +57,7 @@ import com.bnyro.clock.presentation.screens.settings.components.SwitchPref import com.bnyro.clock.presentation.screens.settings.model.SettingsModel 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 @@ -70,7 +72,10 @@ 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) @@ -84,6 +89,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(), @@ -259,6 +282,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 @@ -340,6 +371,26 @@ fun SettingsScreen( 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 @@ -438,4 +489,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 = 60, + 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 = 60, + 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/timer/TimerAlertActivity.kt b/app/src/main/java/com/bnyro/clock/presentation/screens/timer/TimerAlertActivity.kt index 6c791e6f9..d288541fe 100644 --- 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 @@ -5,6 +5,7 @@ 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.presentation.screens.ringing.RingingActivity @@ -14,6 +15,7 @@ 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) override val closeAction = TimerService.TIMER_ALERT_CLOSE_ACTION @@ -27,6 +29,7 @@ class TimerAlertActivity : RingingActivity() { onSnooze = this@TimerAlertActivity::snooze, onReset = this@TimerAlertActivity::reset, label = label, + ringingSince = ringingSince, incrementSeconds = Preferences.instance.getInt( Preferences.timerIncrementSecondsKey, 60 @@ -64,12 +67,18 @@ class TimerAlertActivity : RingingActivity() { } override fun onNewIntent(intent: Intent) { - handleIntent(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() + ) } } 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 index 3483eee2d..73191e77e 100644 --- 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 @@ -1,6 +1,7 @@ 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 @@ -19,6 +20,8 @@ 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 @@ -29,6 +32,8 @@ 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 @Composable fun TimerAlertScreen( @@ -36,22 +41,36 @@ fun TimerAlertScreen( onSnooze: () -> Unit, onReset: () -> Unit, label: String? = null, + ringingSince: Long, incrementSeconds: Int ) { RingingAlert(rememberVectorPainter(Icons.Rounded.Timer)) { - TimerAlertControls(label, incrementSeconds, onDismiss, onSnooze, onReset) + 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) + + // 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 @@ -133,6 +152,7 @@ private fun DefaultPreview() { onSnooze = {}, onReset = {}, label = "Pasta", + ringingSince = 0L, incrementSeconds = 60 ) } 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 12c4f2ffe..f52f5ef8a 100644 --- a/app/src/main/java/com/bnyro/clock/util/Preferences.kt +++ b/app/src/main/java/com/bnyro/clock/util/Preferences.kt @@ -19,6 +19,9 @@ object Preferences { 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 snoozeTimeMinutesKey = "snoozeTimeMinutes" const val alarmTimeoutMinutesKey = "alarmTimeoutMinutes" const val customColorKey = "customColor" 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 3588852d8..354a977b2 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 @@ -36,6 +34,7 @@ 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 kotlinx.coroutines.runBlocking import java.util.Timer import java.util.TimerTask @@ -48,18 +47,7 @@ class AlarmService : Service() { private var currentAlarm: Alarm? = null 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?) { @@ -211,7 +199,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 @@ -220,8 +211,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() @@ -336,8 +327,5 @@ class AlarmService : Service() { 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/TimerService.kt b/app/src/main/java/com/bnyro/clock/util/services/TimerService.kt index 625559232..0540f954c 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 @@ -40,6 +40,10 @@ 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 @@ -63,6 +67,11 @@ class TimerService : Service() { 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) { @@ -114,6 +123,15 @@ 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) { @@ -131,7 +149,9 @@ class TimerService : Service() { ACTION_ALERT_SHOWN, ACTION_ALERT_HIDDEN -> { alertedTimerId = obj.id.takeIf { action == ACTION_ALERT_SHOWN } - if (obj.currentPosition.value == 0) showFinishedNotification(obj) + if (obj.id == ringingTimerId) { + promoteForeground(announcing = action == ACTION_ALERT_HIDDEN) + } } ACTION_ADD_TIME -> { @@ -139,8 +159,7 @@ class TimerService : Service() { // time added starts it running again rather than sitting on a finished timer val finished = obj.currentPosition.value == 0 if (finished) { - stopAudio() - closeAlert(obj) + endRinging(obj) oldnow = System.currentTimeMillis() obj.state.value = WatchState.RUNNING } @@ -154,8 +173,9 @@ class TimerService : Service() { } if (finished) { - val notificationManager = NotificationManagerCompat.from(context) - notificationManager.cancel(finishedNotificationId(obj)) + NotificationManagerCompat.from(context) + .cancel(finishedNotificationId(obj)) + promoteForeground() invokeChangeListener() } @@ -163,8 +183,7 @@ class TimerService : Service() { } TIMER_RESTART -> { - stopAudio() - closeAlert(obj) + endRinging(obj) oldnow = System.currentTimeMillis() @@ -182,6 +201,7 @@ class TimerService : Service() { notificationManager.cancel(finishedNotificationId(obj)) notificationManager.cancel(obj.id) + promoteForeground() invokeChangeListener() updateNotification(obj) } @@ -218,6 +238,10 @@ class TimerService : Service() { player.setAudioAttributes(NotificationHelper.audioAttributes) player.prepare() player.start() + volumeRamp = VolumeRamp( + player, + Preferences.instance.getInt(Preferences.timerVolumeRampSecondsKey, 0) + ).apply { start() } } /** @@ -227,6 +251,9 @@ class TimerService : Service() { if (!isPlaying) return isPlaying = false + volumeRamp?.cancel() + volumeRamp = null + if (mediaPlayer != null) { mediaPlayer?.stop() mediaPlayer?.release() @@ -298,22 +325,12 @@ class TimerService : Service() { return START_STICKY } - play(obj) cancelAlarm(obj) - - val notificationManager = NotificationManagerCompat.from(this) - notificationManager.cancel(obj.id) - - if (timerObjects.size <= 1) { - stopForeground(STOP_FOREGROUND_REMOVE) - } - - showFinishedNotification(obj) - if (fullScreenAlertEnabled) startActivity(alertIntent(obj)) - obj.currentPosition.value = 0 obj.state.value = WatchState.PAUSED + startRinging(obj) + if (timerObjects.none { t -> t.state.value == WatchState.RUNNING }) { releaseWakeLock() } @@ -388,6 +405,14 @@ class TimerService : Service() { } } } + + timerObjects.find { it.id == ringingTimerId }?.let { + val rung = ringDuration / 1000 + if (rung != lastRungSeconds) { + lastRungSeconds = rung + showFinishedNotification(it) + } + } } fun enqueueNew(timerObject: TimerObject) { @@ -431,25 +456,90 @@ 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 }) { releaseWakeLock() } - closeAlert(timerObject) invokeChangeListener() + promoteForeground() val notificationManager = NotificationManagerCompat.from(this) notificationManager.cancel(timerObject.id) notificationManager.cancel(finishedNotificationId(timerObject)) if (timerObjects.isEmpty()) { - stopForeground(STOP_FOREGROUND_REMOVE) stopSelf() } } + /** + * 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 { + stopForeground(STOP_FOREGROUND_REMOVE) + } + } + private fun finishedNotificationId(timerObject: TimerObject) = (Integer.MAX_VALUE / 3) + timerObject.id * 10 @@ -462,6 +552,7 @@ class TimerService : Service() { .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) private fun closeAlert(timerObject: TimerObject) { if (alertedTimerId == timerObject.id) alertedTimerId = null @@ -473,15 +564,39 @@ class TimerService : Service() { ) } - private fun showFinishedNotification(timerObject: TimerObject) { + private fun showFinishedNotification( + timerObject: TimerObject, + rangFor: Long? = null, + announcing: Boolean = false + ) { if (ActivityCompat.checkSelfPermission( this, Manifest.permission.POST_NOTIFICATIONS ) != PackageManager.PERMISSION_GRANTED ) return + NotificationManagerCompat.from(this).notify( + finishedNotificationId(timerObject), + finishedNotification(timerObject, rangFor, announcing) + ) + } + + /** + * 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( @@ -528,17 +643,34 @@ class TimerService : Service() { this, finishedNotificationId + 1, deleteIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) - val notification = NotificationCompat.Builder(this, notificationChannelId) + return NotificationCompat.Builder(this, notificationChannelId) .setSmallIcon(R.drawable.ic_timer) - .setContentTitle(getString(R.string.finished_named_timer, timerObject.label.value)) + .setContentTitle( + 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(alertShowing) + .setSilent(!announces) .apply { - if (fullScreenAlertEnabled && !alertShowing) { + if (ringing) { + setColorized(true) + setColor(TextColor.PrimaryDark.getColorValue(this@TimerService)) + } + if (announces && fullScreenAlertEnabled) { setFullScreenIntent(alertPendingIntent, true) } } @@ -546,8 +678,6 @@ class TimerService : Service() { .addAction(snoozeAction) .addAction(restartAction) .build() - - NotificationManagerCompat.from(this).notify(finishedNotificationId, notification) } private fun pauseResumeAction(timerObject: TimerObject): NotificationCompat.Action { @@ -634,6 +764,8 @@ class TimerService : Service() { 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 TIMER_TIMEOUT_MINUTES = 10 const val TIMER_ALERT_CLOSE_ACTION = "com.bnyro.clock.TIMER_ALERT_CLOSE_ACTION" fun updateStateIntent(action: String, objectId: Int): Intent = diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 248c5f8b7..4c8c04e95 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -10,6 +10,7 @@ About Timer service %1$s timer finished + %1$s timer finished for %2$s Stopwatch is running Stopwatch is paused @@ -106,6 +107,10 @@ Delete Timeout after Select alarm timeout + Select timer timeout + Gradually increase volume + Select how long the volume takes to rise + Never Missed alarms %1$s alarm at %2$s was missed Alarm at %1$s was missed From bcff48d5be18d606ebe48b6c25897cc7fc238180 Mon Sep 17 00:00:00 2001 From: Ris Peng Date: Wed, 2 Sep 2026 00:48:40 +0800 Subject: [PATCH 29/44] configure volume button actions separately for ringing timers --- .../presentation/screens/alarm/AlarmActivity.kt | 3 +++ .../screens/ringing/RingingActivity.kt | 5 ++++- .../screens/settings/SettingsScreen.kt | 17 +++++++++++++++++ .../screens/settings/model/SettingsModel.kt | 9 +++++++++ .../screens/timer/TimerAlertActivity.kt | 2 ++ .../java/com/bnyro/clock/util/Preferences.kt | 1 + app/src/main/res/values/strings.xml | 1 + 7 files changed, 37 insertions(+), 1 deletion(-) 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 92335f1d0..966678ec9 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 @@ -10,6 +10,7 @@ import com.bnyro.clock.App import com.bnyro.clock.domain.model.Alarm 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 @@ -18,6 +19,8 @@ class AlarmActivity : RingingActivity() { override val closeAction = ALARM_ALERT_CLOSE_ACTION + override val volumeButtonActionKey = Preferences.volumeButtonActionKey + override val snoozeAvailable get() = alarm.snoozeEnabled override fun onCreate(savedInstanceState: Bundle?) { 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 index b7b1d7481..ecc8b2fdd 100644 --- 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 @@ -31,6 +31,9 @@ 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 + protected abstract fun dismiss() protected abstract fun snooze() @@ -119,7 +122,7 @@ abstract class RingingActivity : ComponentActivity() { return when ( VolumeButtonAction.valueOf( Preferences.instance.getString( - Preferences.volumeButtonActionKey, + volumeButtonActionKey, VolumeButtonAction.SNOOZE.name ) ?: VolumeButtonAction.SNOOZE.name ) 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 5a130b1c9..7cab74af3 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 @@ -328,6 +328,23 @@ fun SettingsScreen( Preferences.edit { putString(Preferences.timerPickerStyleKey, it.name) } } + 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 { 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 dfaa92270..c5e85c93a 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 @@ -116,6 +116,15 @@ class SettingsModel : ViewModel() { ) ) + var timerVolumeButtonAction by mutableStateOf( + VolumeButtonAction.valueOf( + Preferences.instance.getString( + Preferences.timerVolumeButtonActionKey, + VolumeButtonAction.SNOOZE.name + ) ?: VolumeButtonAction.SNOOZE.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/timer/TimerAlertActivity.kt b/app/src/main/java/com/bnyro/clock/presentation/screens/timer/TimerAlertActivity.kt index d288541fe..3b564b5f0 100644 --- 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 @@ -19,6 +19,8 @@ class TimerAlertActivity : RingingActivity() { override val closeAction = TimerService.TIMER_ALERT_CLOSE_ACTION + override val volumeButtonActionKey = Preferences.timerVolumeButtonActionKey + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) handleIntent(intent) 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 f52f5ef8a..e3bc9bf05 100644 --- a/app/src/main/java/com/bnyro/clock/util/Preferences.kt +++ b/app/src/main/java/com/bnyro/clock/util/Preferences.kt @@ -22,6 +22,7 @@ object Preferences { 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" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4c8c04e95..e61c49455 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -120,6 +120,7 @@ Snooze Dismiss Volume buttons + Volume buttons Volume Nothing Same time From a2223efbf76be92494335bad600f80bd60e33098 Mon Sep 17 00:00:00 2001 From: Ris Peng Date: Wed, 2 Sep 2026 09:00:28 +0800 Subject: [PATCH 30/44] show seconds on the ringing timer screen --- .../presentation/screens/ringing/RingingAlert.kt | 14 +++++++++++--- .../presentation/screens/timer/TimerAlertScreen.kt | 2 +- .../main/java/com/bnyro/clock/util/TimeHelper.kt | 4 ++-- 3 files changed, 14 insertions(+), 6 deletions(-) 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 index 93ca8db17..82d9f5cc1 100644 --- 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 @@ -98,13 +98,21 @@ fun RingingAlert(icon: Painter, controls: @Composable ColumnScope.() -> Unit) { * The time it is now over the name of whatever is ringing, which a reader woken by it reads first. */ @Composable -fun RingingTitle(label: String?) { +fun RingingTitle(label: String?, showSeconds: Boolean = false) { val context = LocalContext.current val time by produceState( - initialValue = TimeHelper.formatTime(context, TimeHelper.getTimeByZone()), + initialValue = TimeHelper.formatTime( + context, + TimeHelper.getTimeByZone(), + showSeconds + ), producer = { while (isActive) { - value = TimeHelper.formatTime(context, TimeHelper.getTimeByZone()) + value = TimeHelper.formatTime( + context, + TimeHelper.getTimeByZone(), + showSeconds + ) delay(1000) } } 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 index 73191e77e..793280399 100644 --- 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 @@ -58,7 +58,7 @@ private fun TimerAlertControls( onSnooze: () -> Unit, onReset: () -> Unit ) { - RingingTitle(label) + RingingTitle(label, showSeconds = true) // the timer does not stop at zero, it goes on counting the wait for an answer val rung by produceState(initialValue = 0L, ringingSince) { 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 c865fa683..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) From 6f98ffb27c2d1aabfd18e5b604edc74cfe4ab07b Mon Sep 17 00:00:00 2001 From: Ris Peng Date: Wed, 2 Sep 2026 09:00:33 +0800 Subject: [PATCH 31/44] support a custom increment amount for each timer Add the increment picker below vibration in the timer sheet and use the global setting as the initial amount for started and saved timers. --- .../clock/domain/model/TimerDescriptor.kt | 3 +- .../bnyro/clock/domain/model/TimerObject.kt | 6 +- .../bnyro/clock/domain/model/TimerSettings.kt | 6 +- .../screens/timer/TimerAlertActivity.kt | 7 +-- .../timer/components/TimerEditSheet.kt | 55 ++++++++++++++++++- .../bnyro/clock/util/services/TimerService.kt | 22 ++++---- 6 files changed, 78 insertions(+), 21 deletions(-) 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 4b324e5d6..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 @@ -19,7 +19,8 @@ data class TimerDescriptor( soundEnabled = settings.soundEnabled, vibrate = settings.vibrate, vibrationPattern = settings.vibrationPattern, - vibrationPatternName = settings.vibrationPatternName + vibrationPatternName = settings.vibrationPatternName, + incrementSeconds = settings.incrementSeconds ) } } 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 dd926a3cb..b4a22f609 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 @@ -15,7 +15,8 @@ data class TimerObject( var soundEnabled: Boolean = true, var vibrate: Boolean = true, var vibrationPattern: List = listOf(0, 1000, 1000, 1000, 1000), - var vibrationPatternName: String = "Default" + var vibrationPatternName: String = "Default", + var incrementSeconds: Int = 60 ) { val secondsLeft: Int get() = ceil(currentPosition.value / 1000.0).toInt() @@ -29,6 +30,7 @@ data class TimerObject( soundEnabled = soundEnabled, vibrate = vibrate, vibrationPattern = vibrationPattern, - vibrationPatternName = vibrationPatternName + vibrationPatternName = vibrationPatternName, + incrementSeconds = incrementSeconds ) } 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 index 6f3b8cff0..99ff5aa96 100644 --- a/app/src/main/java/com/bnyro/clock/domain/model/TimerSettings.kt +++ b/app/src/main/java/com/bnyro/clock/domain/model/TimerSettings.kt @@ -27,7 +27,11 @@ data class TimerSettings( val soundEnabled: Boolean = true, val vibrate: Boolean = true, val vibrationPattern: List = listOf(0, 1000, 1000, 1000, 1000), - val vibrationPatternName: String = "Default" + val vibrationPatternName: String = "Default", + val incrementSeconds: Int = Preferences.instance.getInt( + Preferences.timerIncrementSecondsKey, + 60 + ) ) : Parcelable { companion object { private val exampleTimers = listOf( 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 index 3b564b5f0..dee5cf6c2 100644 --- 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 @@ -16,6 +16,7 @@ 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 @@ -32,10 +33,7 @@ class TimerAlertActivity : RingingActivity() { onReset = this@TimerAlertActivity::reset, label = label, ringingSince = ringingSince, - incrementSeconds = Preferences.instance.getInt( - Preferences.timerIncrementSecondsKey, - 60 - ) + incrementSeconds = incrementSeconds ) } } @@ -82,5 +80,6 @@ class TimerAlertActivity : RingingActivity() { 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/components/TimerEditSheet.kt b/app/src/main/java/com/bnyro/clock/presentation/screens/timer/components/TimerEditSheet.kt index cbaa21725..f30b18f42 100644 --- 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 @@ -1,5 +1,6 @@ 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 @@ -7,6 +8,7 @@ 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 @@ -14,6 +16,7 @@ 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 @@ -34,6 +37,7 @@ 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 @@ -41,6 +45,7 @@ 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 @@ -57,6 +62,7 @@ fun TimerEditSheet( ) { 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) } @@ -66,6 +72,7 @@ fun TimerEditSheet( 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 { mutableIntStateOf(currentTimer.incrementSeconds) } val scrollState = rememberScrollState() val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) @@ -147,6 +154,36 @@ fun TimerEditSheet( 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 = pluralStringResource( + R.plurals.seconds_count, + incrementSeconds, + incrementSeconds + ), + style = MaterialTheme.typography.bodyMedium + ) + } + } } } @@ -183,7 +220,8 @@ fun TimerEditSheet( soundEnabled = soundEnabled, vibrate = vibrationEnabled, vibrationPattern = vibrationPattern, - vibrationPatternName = vibrationPatternName + vibrationPatternName = vibrationPatternName, + incrementSeconds = incrementSeconds ) ) } @@ -212,4 +250,19 @@ fun TimerEditSheet( selectedPattern = vibrationPatternName ) } + if (showIncrementDialog) { + ScrollPickerDialog( + onDismissRequest = { showIncrementDialog = false }, + title = stringResource(R.string.select_timer_increment), + unit = stringResource(R.string.seconds), + value = incrementSeconds, + maxValue = 60, + offset = 1, + label = { it.toString() }, + onValueSet = { + incrementSeconds = it + showIncrementDialog = false + } + ) + } } 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 0540f954c..dd7c81fa5 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 @@ -117,9 +117,6 @@ class TimerService : Service() { } } - private val incrementSeconds - get() = Preferences.instance.getInt(Preferences.timerIncrementSecondsKey, 60) - private val fullScreenAlertEnabled get() = Preferences.instance.getBoolean(Preferences.timerFullScreenAlertKey, true) @@ -164,7 +161,7 @@ class TimerService : Service() { obj.state.value = WatchState.RUNNING } - obj.currentPosition.value += incrementSeconds * 1000 + obj.currentPosition.value += obj.incrementSeconds * 1000 if (obj.state.value == WatchState.RUNNING) { cancelAlarm(obj) @@ -553,6 +550,7 @@ class TimerService : Service() { .putExtra(ID_EXTRA_KEY, timerObject.id) .putExtra(LABEL_EXTRA_KEY, timerObject.label.value) .putExtra(RINGING_SINCE_EXTRA_KEY, ringingSince) + .putExtra(INCREMENT_EXTRA_KEY, timerObject.incrementSeconds) private fun closeAlert(timerObject: TimerObject) { if (alertedTimerId == timerObject.id) alertedTimerId = null @@ -628,7 +626,7 @@ class TimerService : Service() { PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) val snoozeAction = NotificationCompat.Action.Builder( - null, addTimeLabel(), snoozePendingIntent + null, addTimeLabel(timerObject.incrementSeconds), snoozePendingIntent ).build() val alertPendingIntent = PendingIntent.getActivity( @@ -707,18 +705,16 @@ class TimerService : Service() { ) private fun addTimeAction(timerObject: TimerObject) = getAction( - addTimeLabel(), + addTimeLabel(timerObject.incrementSeconds), ACTION_ADD_TIME, 6, timerObject.id ) - private fun addTimeLabel() = incrementSeconds.let { seconds -> - if (seconds == 60) { - getString(R.string.add_one_minute) - } else { - resources.getQuantityString(R.plurals.add_seconds, seconds, seconds) - } + private fun addTimeLabel(seconds: Int) = if (seconds == 60) { + getString(R.string.add_one_minute) + } else { + resources.getQuantityString(R.plurals.add_seconds, seconds, seconds) } fun updateTimer(id: Int, settings: TimerSettings) { @@ -731,6 +727,7 @@ class TimerService : Service() { it.vibrate = settings.vibrate it.vibrationPattern = settings.vibrationPattern it.vibrationPatternName = settings.vibrationPatternName + it.incrementSeconds = settings.incrementSeconds updateNotification(it) } } @@ -765,6 +762,7 @@ class TimerService : Service() { 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" From c444aedb5746bcc8dbd3544215a7d62b915e49e7 Mon Sep 17 00:00:00 2001 From: Ris Peng Date: Wed, 2 Sep 2026 09:19:00 +0800 Subject: [PATCH 32/44] let timers inherit the global increment setting Keep the global increment active until a timer has an explicit override. Add a Default choice to remove the override and resume following the setting. --- .../bnyro/clock/domain/model/TimerObject.kt | 9 +++++++- .../bnyro/clock/domain/model/TimerSettings.kt | 5 +--- .../timer/components/TimerEditSheet.kt | 23 ++++++++++--------- .../bnyro/clock/util/services/TimerService.kt | 8 +++---- app/src/main/res/values/strings.xml | 1 + 5 files changed, 26 insertions(+), 20 deletions(-) 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 b4a22f609..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 @@ -2,6 +2,7 @@ package com.bnyro.clock.domain.model import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf +import com.bnyro.clock.util.Preferences import kotlin.math.ceil data class TimerObject( @@ -16,11 +17,17 @@ data class TimerObject( var vibrate: Boolean = true, var vibrationPattern: List = listOf(0, 1000, 1000, 1000, 1000), var vibrationPatternName: String = "Default", - var incrementSeconds: Int = 60 + 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, 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 index 99ff5aa96..b1d249ef0 100644 --- a/app/src/main/java/com/bnyro/clock/domain/model/TimerSettings.kt +++ b/app/src/main/java/com/bnyro/clock/domain/model/TimerSettings.kt @@ -28,10 +28,7 @@ data class TimerSettings( val vibrate: Boolean = true, val vibrationPattern: List = listOf(0, 1000, 1000, 1000, 1000), val vibrationPatternName: String = "Default", - val incrementSeconds: Int = Preferences.instance.getInt( - Preferences.timerIncrementSecondsKey, - 60 - ) + val incrementSeconds: Int? = null ) : Parcelable { companion object { private val exampleTimers = listOf( 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 index f30b18f42..a3493cc80 100644 --- 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 @@ -49,6 +49,7 @@ 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) @@ -72,7 +73,7 @@ fun TimerEditSheet( 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 { mutableIntStateOf(currentTimer.incrementSeconds) } + var incrementSeconds by remember { mutableStateOf(currentTimer.incrementSeconds) } val scrollState = rememberScrollState() val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) @@ -175,11 +176,9 @@ fun TimerEditSheet( style = MaterialTheme.typography.titleLarge ) Text( - text = pluralStringResource( - R.plurals.seconds_count, - incrementSeconds, - incrementSeconds - ), + text = incrementSeconds?.let { + pluralStringResource(R.plurals.seconds_count, it, it) + } ?: stringResource(R.string.default_increment), style = MaterialTheme.typography.bodyMedium ) } @@ -251,16 +250,18 @@ fun TimerEditSheet( ) } if (showIncrementDialog) { + val defaultLabel = stringResource(R.string.default_increment) ScrollPickerDialog( onDismissRequest = { showIncrementDialog = false }, title = stringResource(R.string.select_timer_increment), unit = stringResource(R.string.seconds), - value = incrementSeconds, - maxValue = 60, - offset = 1, - label = { it.toString() }, + value = incrementSeconds + ?: Preferences.instance.getInt(Preferences.timerIncrementSecondsKey, 60), + maxValue = 61, + offset = 0, + label = { if (it == 0) defaultLabel else it.toString() }, onValueSet = { - incrementSeconds = it + incrementSeconds = it.takeIf { seconds -> seconds > 0 } showIncrementDialog = false } ) 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 dd7c81fa5..11ccc2c0e 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 @@ -161,7 +161,7 @@ class TimerService : Service() { obj.state.value = WatchState.RUNNING } - obj.currentPosition.value += obj.incrementSeconds * 1000 + obj.currentPosition.value += obj.effectiveIncrementSeconds * 1000 if (obj.state.value == WatchState.RUNNING) { cancelAlarm(obj) @@ -550,7 +550,7 @@ class TimerService : Service() { .putExtra(ID_EXTRA_KEY, timerObject.id) .putExtra(LABEL_EXTRA_KEY, timerObject.label.value) .putExtra(RINGING_SINCE_EXTRA_KEY, ringingSince) - .putExtra(INCREMENT_EXTRA_KEY, timerObject.incrementSeconds) + .putExtra(INCREMENT_EXTRA_KEY, timerObject.effectiveIncrementSeconds) private fun closeAlert(timerObject: TimerObject) { if (alertedTimerId == timerObject.id) alertedTimerId = null @@ -626,7 +626,7 @@ class TimerService : Service() { PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) val snoozeAction = NotificationCompat.Action.Builder( - null, addTimeLabel(timerObject.incrementSeconds), snoozePendingIntent + null, addTimeLabel(timerObject.effectiveIncrementSeconds), snoozePendingIntent ).build() val alertPendingIntent = PendingIntent.getActivity( @@ -705,7 +705,7 @@ class TimerService : Service() { ) private fun addTimeAction(timerObject: TimerObject) = getAction( - addTimeLabel(timerObject.incrementSeconds), + addTimeLabel(timerObject.effectiveIncrementSeconds), ACTION_ADD_TIME, 6, timerObject.id diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e61c49455..f247f7cdc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -99,6 +99,7 @@ Use big start button Increment amount Select increment amount + Default Show picker Hide picker Picker style From 6a72b8927c248b1d77c66492834a2d138ee4ec0b Mon Sep 17 00:00:00 2001 From: Ris Peng Date: Wed, 2 Sep 2026 09:23:31 +0800 Subject: [PATCH 33/44] label the inherited increment picker value as zero --- .../presentation/screens/timer/components/TimerEditSheet.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 index a3493cc80..6cea33adb 100644 --- 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 @@ -250,7 +250,6 @@ fun TimerEditSheet( ) } if (showIncrementDialog) { - val defaultLabel = stringResource(R.string.default_increment) ScrollPickerDialog( onDismissRequest = { showIncrementDialog = false }, title = stringResource(R.string.select_timer_increment), @@ -259,7 +258,7 @@ fun TimerEditSheet( ?: Preferences.instance.getInt(Preferences.timerIncrementSecondsKey, 60), maxValue = 61, offset = 0, - label = { if (it == 0) defaultLabel else it.toString() }, + label = { it.toString() }, onValueSet = { incrementSeconds = it.takeIf { seconds -> seconds > 0 } showIncrementDialog = false From af6c64b57508f91f726a6fe7b2c7c4ce3626b3fa Mon Sep 17 00:00:00 2001 From: Ris Peng Date: Wed, 2 Sep 2026 09:28:17 +0800 Subject: [PATCH 34/44] show the expiry time on the ringing timer screen Keep the top time fixed at expiry because the overrun display already tracks the subsequent wait. --- .../screens/ringing/RingingAlert.kt | 28 ++++++++++++------- .../screens/timer/TimerAlertScreen.kt | 10 +++++-- 2 files changed, 26 insertions(+), 12 deletions(-) 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 index 82d9f5cc1..a3b504433 100644 --- 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 @@ -39,6 +39,7 @@ 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 @@ -95,18 +96,24 @@ fun RingingAlert(icon: Painter, controls: @Composable ColumnScope.() -> Unit) { } /** - * The time it is now over the name of whatever is ringing, which a reader woken by it reads first. + * The time a ringing screen leads with, which is now for an alarm that is still ringing 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) { +fun RingingTitle(label: String?, showSeconds: Boolean = false, time: ZonedDateTime? = null) { val context = LocalContext.current - val time by produceState( - initialValue = TimeHelper.formatTime( - context, - TimeHelper.getTimeByZone(), + val shownTime = if (time != null) { + TimeHelper.formatTime(context, time, showSeconds) + } else { + val now by produceState( + initialValue = TimeHelper.formatTime( + context, + TimeHelper.getTimeByZone(), + showSeconds + ), showSeconds - ), - producer = { + ) { while (isActive) { value = TimeHelper.formatTime( context, @@ -116,9 +123,10 @@ fun RingingTitle(label: String?, showSeconds: Boolean = false) { delay(1000) } } - ) + now + } Text( - text = time, + text = shownTime, style = MaterialTheme.typography.displayMedium ) label?.let { 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 index 793280399..79c98f4a9 100644 --- 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 @@ -34,6 +34,8 @@ 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( @@ -58,7 +60,11 @@ private fun TimerAlertControls( onSnooze: () -> Unit, onReset: () -> Unit ) { - RingingTitle(label, showSeconds = true) + 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) { @@ -152,7 +158,7 @@ private fun DefaultPreview() { onSnooze = {}, onReset = {}, label = "Pasta", - ringingSince = 0L, + ringingSince = System.currentTimeMillis(), incrementSeconds = 60 ) } From 0c72f10f39c23002d292ebf0c68b5ee47f4cd0f6 Mon Sep 17 00:00:00 2001 From: Ris Peng Date: Wed, 2 Sep 2026 09:40:22 +0800 Subject: [PATCH 35/44] show the scheduled alarm time throughout ringing and snoozing Keep the ringing screen's main time fixed to the scheduled alarm instead of the current clock time, including after snoozing. The status bar continues to show the current time. --- .../screens/alarm/AlarmActivity.kt | 3 ++- .../screens/alarm/AlarmAlertScreen.kt | 20 ++++++++++++++----- .../screens/ringing/RingingAlert.kt | 5 ++--- 3 files changed, 19 insertions(+), 9 deletions(-) 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 966678ec9..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 @@ -31,7 +31,8 @@ class AlarmActivity : RingingActivity() { onSnooze = this@AlarmActivity::snooze, label = alarm.label, snoozeEnabled = alarm.snoozeEnabled, - snoozeTime = alarm.snoozeMinutes + snoozeTime = alarm.snoozeMinutes, + alarmTimeMillis = alarm.time ) } 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 2a481257d..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 @@ -33,6 +33,9 @@ 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 java.time.LocalDate +import java.time.LocalTime +import java.time.ZoneId @Composable fun AlarmAlertScreen( @@ -40,22 +43,29 @@ fun AlarmAlertScreen( onSnooze: (minutes: Int) -> Unit, label: String? = null, snoozeEnabled: Boolean, - snoozeTime: Int + snoozeTime: Int, + alarmTimeMillis: Long ) { RingingAlert(painterResource(id = R.drawable.ic_alarm)) { - AlarmControls(label, snoozeTime, snoozeEnabled, onSnooze, onDismiss) + 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 ) { - RingingTitle(label) + RingingTitle( + label, + time = LocalDate.now() + .atTime(LocalTime.ofSecondOfDay(alarmTimeMillis / 1000)) + .atZone(ZoneId.systemDefault()) + ) Column( Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally @@ -126,7 +136,7 @@ private fun AlarmControls( @Composable private fun DefaultPreview() { AlarmAlertScreen(onDismiss = {}, onSnooze = {}, snoozeTime = 10, label = "Test Alarm", - snoozeEnabled = true + snoozeEnabled = true, alarmTimeMillis = 7 * 60 * 60 * 1000L + 30 * 60 * 1000L ) } @@ -135,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/ringing/RingingAlert.kt b/app/src/main/java/com/bnyro/clock/presentation/screens/ringing/RingingAlert.kt index a3b504433..a5598c1d6 100644 --- 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 @@ -96,9 +96,8 @@ fun RingingAlert(icon: Painter, controls: @Composable ColumnScope.() -> Unit) { } /** - * The time a ringing screen leads with, which is now for an alarm that is still ringing and the - * moment a timer finished, over the name of whatever is ringing, which a reader woken by it - * reads first. + * 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) { From 7493632e97660e0c295cccea2583fef5f387e3f7 Mon Sep 17 00:00:00 2001 From: Ris Peng Date: Wed, 2 Sep 2026 14:48:13 +0800 Subject: [PATCH 36/44] add label icons to active and saved timer names --- .../timer/components/SavedTimerItem.kt | 27 +++++++++++++------ .../screens/timer/components/TimerItem.kt | 25 +++++++++++------ 2 files changed, 36 insertions(+), 16 deletions(-) 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 index 40c98fd65..7efbe85ee 100644 --- 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 @@ -5,11 +5,14 @@ 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 @@ -68,14 +71,22 @@ fun SavedTimerItem( verticalAlignment = Alignment.CenterVertically ) { Column(modifier = Modifier.weight(1f)) { - Text( - text = timer.label, - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Normal, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + 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()}", 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 47bbffe9a..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,6 +14,7 @@ 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.MoreTime import androidx.compose.material.icons.filled.Notifications @@ -76,14 +77,22 @@ fun TimerItem( val mutedContentColor = MaterialTheme.colorScheme.onSurfaceVariant Column(modifier = Modifier.weight(1f)) { - Text( - text = obj.label.value, - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Normal, - color = mutedContentColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Label, + contentDescription = null, + tint = mutedContentColor + ) + Spacer(modifier = Modifier.width(5.dp)) + Text( + text = obj.label.value, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Normal, + color = mutedContentColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } Text( text = if (isFinished) "0:00:00" else "$hours:${minutes.addZero()}:${seconds.addZero()}", From d3fa6bad88960fe4d9ae03a436a8c08759dbc0b3 Mon Sep 17 00:00:00 2001 From: Ris Peng Date: Sat, 5 Sep 2026 17:25:11 +0800 Subject: [PATCH 37/44] widen the volume ramp picker to sixty seconds and shorten its title --- .../clock/presentation/screens/settings/SettingsScreen.kt | 4 ++-- app/src/main/res/values/strings.xml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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 f92096a7c..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 @@ -539,7 +539,7 @@ fun SettingsScreen( title = stringResource(R.string.select_volume_ramp), unit = stringResource(R.string.seconds), value = alarmVolumeRampSeconds, - maxValue = 60, + maxValue = 61, offset = 0, label = { it.toString() }, onValueSet = { @@ -555,7 +555,7 @@ fun SettingsScreen( title = stringResource(R.string.select_volume_ramp), unit = stringResource(R.string.seconds), value = timerVolumeRampSeconds, - maxValue = 60, + maxValue = 61, offset = 0, label = { it.toString() }, onValueSet = { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 579217826..dc9d30360 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -110,7 +110,7 @@ Select alarm timeout Select timer timeout Gradually increase volume - Select how long the volume takes to rise + Select volume ramp Never Missed alarms %1$s alarm at %2$s was missed From 283705d0539265435458bc216d0e4c21e9c6015b Mon Sep 17 00:00:00 2001 From: Ris Peng Date: Sat, 5 Sep 2026 17:37:23 +0800 Subject: [PATCH 38/44] preserve the upcoming alarm notification channel Startup cleanup deleted the pre-alarm channel, cancelling visible upcoming alarm notifications and resetting user channel preferences. Include it in the retained channel list. --- app/src/main/java/com/bnyro/clock/util/NotificationHelper.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 702573b22..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,6 +9,7 @@ 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" @@ -97,6 +98,6 @@ object NotificationHelper { ) nManager.createNotificationChannelsCompat(channels) - nManager.deleteUnlistedNotificationChannels(channels.map { it.id }) + nManager.deleteUnlistedNotificationChannels(channels.map { it.id } + PreAlarmReceiver.CHANNEL_ID) } } From bcc79f0c6c00c4e79b543bb3a522dbe10af8ec7b Mon Sep 17 00:00:00 2001 From: Ris Peng Date: Sat, 5 Sep 2026 17:37:23 +0800 Subject: [PATCH 39/44] migrate saved timers and the large start button preference Saved timers changed from comma-separated seconds to JSON and the large start button key was renamed, causing upgrades to lose both preferences. Read the old keys once and store their values in the new format. --- .../java/com/bnyro/clock/util/Preferences.kt | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) 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 b533370b2..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 @@ -37,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( @@ -50,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") + } } } From 5f29d6247a879495e146e99f8177425f46f30458 Mon Sep 17 00:00:00 2001 From: Ris Peng Date: Sat, 5 Sep 2026 17:37:23 +0800 Subject: [PATCH 40/44] fix timer broadcast naming and countdown calculations Use timer-owned broadcast naming in base Clock You code. Measure countdown deltas with elapsed time so wall-clock changes cannot alter running timers, and rescale remaining time when the running duration changes so countdown and progress remain consistent. --- .../bnyro/clock/util/services/TimerService.kt | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) 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 11ccc2c0e..daa96bc3f 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 @@ -20,6 +20,7 @@ import android.os.Build import android.os.Handler 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 @@ -55,7 +56,7 @@ class TimerService : Service() { private var mediaPlayer: MediaPlayer? = null private var isPlaying = false - private var oldnow = System.currentTimeMillis() + private var oldnow = SystemClock.elapsedRealtime() private val vibrator: Vibrator by lazy { getSystemService(Context.VIBRATOR_SERVICE) as Vibrator } @@ -157,7 +158,7 @@ class TimerService : Service() { val finished = obj.currentPosition.value == 0 if (finished) { endRinging(obj) - oldnow = System.currentTimeMillis() + oldnow = SystemClock.elapsedRealtime() obj.state.value = WatchState.RUNNING } @@ -182,7 +183,7 @@ class TimerService : Service() { TIMER_RESTART -> { endRinging(obj) - oldnow = System.currentTimeMillis() + oldnow = SystemClock.elapsedRealtime() // 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 @@ -387,7 +388,7 @@ class TimerService : Service() { } private fun updateState() { - val now = System.currentTimeMillis() + val now = SystemClock.elapsedRealtime() val delta = now - oldnow oldnow = now @@ -719,8 +720,13 @@ class TimerService : Service() { fun updateTimer(id: Int, settings: TimerSettings) { timerObjects.firstOrNull { it.id == id }?.let { + 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 = settings.seconds * 1000 + it.initialPosition.value = duration it.soundName = settings.soundName it.soundUri = settings.soundUri it.soundEnabled = settings.soundEnabled @@ -728,6 +734,7 @@ class TimerService : Service() { it.vibrationPattern = settings.vibrationPattern it.vibrationPatternName = settings.vibrationPatternName it.incrementSeconds = settings.incrementSeconds + if (running) scheduleAlarm(it) updateNotification(it) } } @@ -757,7 +764,7 @@ class TimerService : Service() { private const val UPDATE_DELAY = 100 const val TIMER_RESTART = "timer_restart" const val ACTION_ADD_TIME = "add_time" - const val UPDATE_STATE_SCHEME = "jaytimer" + 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" From 2ae77de1ebab4a177bb29cf15e694275171b7d71 Mon Sep 17 00:00:00 2001 From: Ris Peng Date: Sat, 5 Sep 2026 18:00:08 +0800 Subject: [PATCH 41/44] migrate stored alarms to the immediate vibration default The constructor used an immediate vibration pattern while the SQL default and existing alarms retained the initial delay. Rebuild the alarms table in a schema migration, update alarms using the old default, and verify the migration by opening a version 12 database through Room. --- .../13.json | 207 ++++++++++++++++++ .../bnyro/clock/data/database/AppDatabase.kt | 21 +- .../com/bnyro/clock/domain/model/Alarm.kt | 2 +- .../data/database/AppDatabaseMigrationTest.kt | 74 +++++++ 4 files changed, 301 insertions(+), 3 deletions(-) create mode 100644 app/schemas/com.bnyro.clock.data.database.AppDatabase/13.json create mode 100644 app/src/test/java/com/bnyro/clock/data/database/AppDatabaseMigrationTest.kt 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/java/com/bnyro/clock/data/database/AppDatabase.kt b/app/src/main/java/com/bnyro/clock/data/database/AppDatabase.kt index 8b9d68683..fe5af39d9 100644 --- a/app/src/main/java/com/bnyro/clock/data/database/AppDatabase.kt +++ b/app/src/main/java/com/bnyro/clock/data/database/AppDatabase.kt @@ -19,7 +19,7 @@ import com.bnyro.clock.domain.model.TimeZone @Database( entities = [TimeZone::class, Alarm::class], - version = 12, + version = 13, autoMigrations = [ AutoMigration( from = 2, @@ -91,6 +91,22 @@ abstract class AppDatabase : RoomDatabase() { } } + val MIGRATION_12_13 = object : Migration(12, 13) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE alarms RENAME TO temp_table") + 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 '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)") + db.execSQL( + "INSERT INTO alarms (id, time, label, enabled, days, vibrate, soundName, soundUri, snoozeEnabled, snoozeMinutes, soundEnabled, vibrationPattern, vibrationPatternName, dismissedAt, startDate, repeatInterval, repeatUnit, repeatAnchor, repeatDuration, repeatDurationUnit, endDate, endOccurrences, advanced) " + + "SELECT id, time, label, enabled, days, vibrate, soundName, soundUri, snoozeEnabled, snoozeMinutes, soundEnabled, vibrationPattern, vibrationPatternName, dismissedAt, startDate, repeatInterval, repeatUnit, repeatAnchor, repeatDuration, repeatDurationUnit, endDate, endOccurrences, advanced FROM temp_table" + ) + db.execSQL( + "UPDATE alarms SET vibrationPattern = '0,1000,1000,1000,1000' " + + "WHERE vibrationPattern = '1000,1000,1000,1000,1000' AND vibrationPatternName = 'Default'" + ) + db.execSQL("DROP TABLE temp_table") + } + } + fun getDatabase(context: Context): AppDatabase { return INSTANCE ?: synchronized(this) { val targetContext = if (Build.VERSION.SDK_INT >= 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 b88891b9e..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 = listOf(0, 1000, 1000, 1000, 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/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" + } +} From d4afa0e15d52adecfd3ff15bd6bd94cd7c4553e8 Mon Sep 17 00:00:00 2001 From: Ris Peng Date: Sun, 6 Sep 2026 09:34:01 +0800 Subject: [PATCH 42/44] default the timer volume buttons to dismiss Snoozing a ringing timer only buys it another increment, which rarely matches what a volume key press while reaching for a ringing phone should do there; the keys now end the timer instead. Alarms keep the snooze default, and a volume button action chosen in the settings still applies to both, since only the unset preference falls back to the new default. --- .../clock/presentation/screens/ringing/RingingActivity.kt | 7 +++++-- .../presentation/screens/settings/model/SettingsModel.kt | 4 ++-- .../clock/presentation/screens/timer/TimerAlertActivity.kt | 3 +++ 3 files changed, 10 insertions(+), 4 deletions(-) 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 index ecc8b2fdd..02e6adc42 100644 --- 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 @@ -34,6 +34,9 @@ abstract class RingingActivity : ComponentActivity() { /** 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() @@ -123,8 +126,8 @@ abstract class RingingActivity : ComponentActivity() { VolumeButtonAction.valueOf( Preferences.instance.getString( volumeButtonActionKey, - VolumeButtonAction.SNOOZE.name - ) ?: VolumeButtonAction.SNOOZE.name + volumeButtonActionDefault.name + ) ?: volumeButtonActionDefault.name ) ) { VolumeButtonAction.SNOOZE -> { 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 c1837ce06..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 @@ -129,8 +129,8 @@ class SettingsModel : ViewModel() { VolumeButtonAction.valueOf( Preferences.instance.getString( Preferences.timerVolumeButtonActionKey, - VolumeButtonAction.SNOOZE.name - ) ?: VolumeButtonAction.SNOOZE.name + VolumeButtonAction.DISMISS.name + ) ?: VolumeButtonAction.DISMISS.name ) ) 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 index dee5cf6c2..b8f444794 100644 --- 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 @@ -8,6 +8,7 @@ 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 @@ -22,6 +23,8 @@ class TimerAlertActivity : RingingActivity() { override val volumeButtonActionKey = Preferences.timerVolumeButtonActionKey + override val volumeButtonActionDefault = VolumeButtonAction.DISMISS + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) handleIntent(intent) From 9eba419959cb9e3c3a7af9822bb0a6fca751eda8 Mon Sep 17 00:00:00 2001 From: Ris Peng Date: Sun, 6 Sep 2026 21:06:48 +0800 Subject: [PATCH 43/44] resolve alarm filtering layout with item animations --- .../presentation/screens/alarm/AlarmScreen.kt | 69 ++----------------- 1 file changed, 4 insertions(+), 65 deletions(-) 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 887f13aa7..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 @@ -177,67 +177,6 @@ fun AlarmScreen( .fillMaxSize() .padding(pv) ) { -<<<<<<< HEAD - item(key = "filters") { - if (alarmModel.showFilter) { - AlarmFilterSection( - filters, - { alarmModel.updateLabelFilter(it) }, - { alarmModel.updateWeekDayFilter(it) }, - { alarmModel.updateStartTimeFilter(it) }, - { alarmModel.updateEndTimeFilter(it) }, - ) - } - } - - items( - items = alarms, - key = { it.id } - ) { alarm -> - val isSelected = selectedAlarmIds.contains(alarm.id) - - AlarmItem( - alarm = alarm, - isSelected = isSelected, - isSelectionMode = isSelectionMode, - onLongClick = { alarmItem -> - if (!isSelectionMode) { - selectedAlarmIds.add(alarmItem.id) - } - }, - onClick = { alarmItem -> - if (isSelectionMode) { - if (isSelected) { - selectedAlarmIds.remove(alarmItem.id) - } else { - selectedAlarmIds.add(alarmItem.id) - } - } else { - onAlarm.invoke(alarmItem.id, alarmItem.advanced) - } - }, - onDeleteAlarm = { alarmItem -> - alarmModel.deleteAlarm(alarmItem) - }, - onDismissAlarm = { alarmItem -> - alarmModel.dismissUpcomingAlarm(alarmItem) - }, - onUpdateAlarm = { updatedAlarm -> - if (!isSelectionMode) { - alarmModel.updateAlarm(updatedAlarm) - - if (updatedAlarm.enabled) { - AlarmHelper.showAlarmScheduledToast(context, updatedAlarm) - } - } - }, - modifier = Modifier.animateItem(ItemFade, ItemSlide, ItemFade) - ) - } - - item(key = "bottomSpacer") { - Spacer(modifier = Modifier.height(80.dp)) -======= if (alarmModel.showFilter) { AlarmFilterSection( filters, @@ -255,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) @@ -293,15 +232,15 @@ fun AlarmScreen( AlarmHelper.showAlarmScheduledToast(context, updatedAlarm) } } - } + }, + modifier = Modifier.animateItem(ItemFade, ItemSlide, ItemFade) ) } - item { + item(key = "bottomSpacer") { Spacer(modifier = Modifier.height(80.dp)) } } ->>>>>>> main } } From f066ad7cec02fda8f432b5f4fd3f9a91937a4305 Mon Sep 17 00:00:00 2001 From: Ris Peng Date: Tue, 8 Sep 2026 15:16:03 +0800 Subject: [PATCH 44/44] support android 6 timer cleanup and optional gravity sensors --- .../presentation/screens/ringing/RingingActivity.kt | 12 +++++------- .../com/bnyro/clock/util/services/TimerService.kt | 5 +++-- 2 files changed, 8 insertions(+), 9 deletions(-) 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 index 02e6adc42..5499ede56 100644 --- 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 @@ -50,8 +50,8 @@ abstract class RingingActivity : ComponentActivity() { 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 val gravitySensor: Sensor? by lazy { + sensorManager.getDefaultSensor(Sensor.TYPE_GRAVITY) } private var facingDownInitially: Boolean? = null @@ -145,11 +145,9 @@ abstract class RingingActivity : ComponentActivity() { override fun onResume() { super.onResume() - sensorManager.registerListener( - sensorEventListener, - gravitySensor, - SensorManager.SENSOR_DELAY_NORMAL - ) + gravitySensor?.let { + sensorManager.registerListener(sensorEventListener, it, SensorManager.SENSOR_DELAY_NORMAL) + } } override fun onPause() { 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 daa96bc3f..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 @@ -28,6 +28,7 @@ import android.util.Log import androidx.annotation.RequiresApi 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 @@ -317,7 +318,7 @@ 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 @@ -534,7 +535,7 @@ class TimerService : Service() { if (counting != null) { startForeground(counting.id, getNotification(counting)) } else { - stopForeground(STOP_FOREGROUND_REMOVE) + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) } }