3 Commits
Author SHA1 Message Date
MrBlakeandClaude Opus 5.5 7812f62c0f uhr: sleep sounds, sunrise alarm, reorder world clock, rename timers; 0.2.0
- own generated sleep sounds (rain, waves, brown noise) with sleep timer and fade-out
- sunrise: 15 min before the bedtime wake alarm, a full-screen screen brightens from ember to daylight
- world clock: long-press drag to reorder, with haptic ticks; order persists
- timers: tap the title to rename

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:49:09 +02:00
MrBlakeandClaude Opus 5.5 08633f918c uhr: smooth card expand, audible sound demos, adaptive charging note; 0.1.2
- one spring for expand, content fade and neighbour placement; fixed-height header with
  cross-faded label; rotating chevron (release build: 0.5 % janky frames while toggling)
- sound preview on the alarm stream (audible in silent/DND), 8 s demo, playing indicator
- settings explain that alarms are published via setAlarmClock, which system adaptive
  charging uses; apps cannot control charging themselves

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:28:36 +02:00
MrBlakeandClaude Opus 5.5 62867fcd07 fix(uhr): time picker digits clipped, dial haptics; 0.1.1
- the theme mapped displayLarge to the 88sp clock style, which the M3 time picker uses for its
  hour/minute fields; displayLarge/headline are back to M3 defaults
- tick on every value the dial passes, firmer click when hours settle and on OK

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:22:32 +02:00
35 changed files with 804 additions and 101 deletions
+2 -1
View File
@@ -51,7 +51,8 @@ A clock and alarm app with the look and flow of the Pixel clock: alarms in expan
- **Ringing done right:** a full-screen screen over the lock screen with slide to snooze or stop, alarm-stream audio that gets louder gradually, vibration, automatic silence after a set time, and notifications for upcoming, snoozed and missed alarms. Volume keys can snooze or stop.
- **No network permission at all.** World clock cities come from the system time zone database (names localised offline), sounds from the device, the app's own tone, or your own audio files.
- **Also:** days and labels per alarm, pause until a date, skip the next ring, timers with ring progress and +1:00 that keep running in the background with a live countdown notification, stopwatch with fastest/slowest laps, analog or digital clock, wallpaper colours (Material You), three home-screen widgets (live digital clock, analog clock, next alarm).
- **Coming next:** widget style options like Wetter, screensaver/night clock, sunrise alarm, sleep sounds, reordering world clocks.
- **Bedtime extras:** own sleep sounds (rain, waves, deep noise) with a sleep timer that fades out, and a sunrise option that slowly brightens the screen 15 minutes before the wake-up alarm.
- **Coming next:** widget style options like Wetter, screensaver/night clock.
<p align="center">
<img src="docs/screenshots/uhr/S01-alarms-dark.png" width="200" alt="Alarm list">
+3 -3
View File
@@ -11,11 +11,11 @@ Device checks on a Pixel 10 Pro XL (Android 16) via a debug-only activity that s
| S03 Time picker | 2026-10-17 | done | – | – |
| S04 Sound picker | 2026-10-17 | done | – | own file not device-tested |
| S05 Pause alarm | 2026-10-17 | done | – | – |
| S06 Clock | 2026-10-17 | done | reorder cities | city names offline via ICU exemplar cities |
| S06 Clock | 2026-10-17 | done | – | city names offline via ICU exemplar cities |
| S07 Add city | 2026-10-17 | done | – | – |
| S08 Timer | 2026-10-17 | done | label edit UI | verified on device: 5 s timer rang in the background, stopped from the notification |
| S08 Timer | 2026-10-17 | done | – | verified on device: 5 s timer rang in the background, stopped from the notification |
| S09 Stopwatch | 2026-10-17 | done | – | – |
| S10 Bedtime | 2026-10-17 | partial | sunrise alarm, sleep sounds | – |
| S10 Bedtime | 2026-10-17 | done | sunrise full-screen not confirmed on device | sleep sounds generated in-house; the sunrise needs the full-screen-intent permission, which Android 14+ does not grant sideloaded apps by default (the app asks) |
| S11 Ringing | 2026-10-17 | done | – | – |
| S12 Timer ringing | 2026-10-17 | done | – | shares the ringing screen |
| S13 Settings | 2026-10-17 | done | timer sound picker, home time zone | – |
Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 54 KiB

+4
View File
@@ -65,6 +65,10 @@ object WColor {
val timerProgress = Color(0xFF7EC4A8)
val nightText = Color(0xFFC4433A)
val nightBg = Color(0xFF000000)
val sunriseNight = Color(0xFF0D0710)
val sunriseEmber = Color(0xFF5A1A10)
val sunriseDay = Color(0xFFFFD9A0)
val sunriseSun = Color(0xFFFFF3D6)
}
object WType {
+310 -51
View File
@@ -1,45 +1,230 @@
{
"_about": "Uhr tokens. Roles read by eye from replica/uhr/screens/ref-01..07 (Pixel Clock, dark first). Own values, no brand colour copied; on Android 12+ dynamic colour (wallpaper) replaces the accent roles. Check: python3 ~/.claude/skills/replica-design/contrast.py replica/uhr/design/tokens.json",
"color": {
"bg": "#f8f9ff", "surface": "#ffffff", "card": "#eef0f6", "card-high": "#e2e5ec",
"border": "#c3c6cf", "border-input": "#73777f",
"text": "#191c20", "text-muted": "#43474e", "text-off": "#5c6068",
"accent": "#2f5ea8", "on-accent": "#ffffff",
"accent-container": "#d6e3ff", "on-accent-container": "#001b3e",
"fab": "#a8c7fa", "on-fab": "#0b2a55",
"chip-on": "#2f5ea8", "on-chip-on": "#ffffff",
"secondary-pill": "#dfe2eb", "on-secondary-pill": "#191c20",
"stop": "#ffd8e4", "on-stop": "#4a1426",
"bg": "#f8f9ff",
"surface": "#ffffff",
"card": "#eef0f6",
"card-high": "#e2e5ec",
"border": "#c3c6cf",
"border-input": "#73777f",
"text": "#191c20",
"text-muted": "#43474e",
"text-off": "#5c6068",
"accent": "#2f5ea8",
"on-accent": "#ffffff",
"accent-container": "#d6e3ff",
"on-accent-container": "#001b3e",
"fab": "#a8c7fa",
"on-fab": "#0b2a55",
"chip-on": "#2f5ea8",
"on-chip-on": "#ffffff",
"secondary-pill": "#dfe2eb",
"on-secondary-pill": "#191c20",
"stop": "#ffd8e4",
"on-stop": "#4a1426",
"danger": "#ba1a1a",
"nav": "#eceef4", "nav-indicator": "#d6e3ff",
"dark-bg": "#111318", "dark-surface": "#111318", "dark-card": "#1d2024", "dark-card-high": "#272a2f",
"dark-border": "#43474e", "dark-border-input": "#8d9199",
"dark-text": "#e2e2e9", "dark-text-muted": "#c3c6cf", "dark-text-off": "#9a9ea6",
"dark-accent": "#a8c7fa", "dark-on-accent": "#062e6f",
"dark-accent-container": "#244a80", "dark-on-accent-container": "#d6e3ff",
"dark-fab": "#a8c7fa", "dark-on-fab": "#062e6f",
"dark-chip-on": "#a8c7fa", "dark-on-chip-on": "#062e6f",
"dark-secondary-pill": "#3a3d43", "dark-on-secondary-pill": "#e2e2e9",
"dark-stop": "#f2b8c9", "dark-on-stop": "#4a1426",
"nav": "#eceef4",
"nav-indicator": "#d6e3ff",
"dark-bg": "#111318",
"dark-surface": "#111318",
"dark-card": "#1d2024",
"dark-card-high": "#272a2f",
"dark-border": "#43474e",
"dark-border-input": "#8d9199",
"dark-text": "#e2e2e9",
"dark-text-muted": "#c3c6cf",
"dark-text-off": "#9a9ea6",
"dark-accent": "#a8c7fa",
"dark-on-accent": "#062e6f",
"dark-accent-container": "#244a80",
"dark-on-accent-container": "#d6e3ff",
"dark-fab": "#a8c7fa",
"dark-on-fab": "#062e6f",
"dark-chip-on": "#a8c7fa",
"dark-on-chip-on": "#062e6f",
"dark-secondary-pill": "#3a3d43",
"dark-on-secondary-pill": "#e2e2e9",
"dark-stop": "#f2b8c9",
"dark-on-stop": "#4a1426",
"dark-danger": "#ffb4ab",
"dark-nav": "#1d2024", "dark-nav-indicator": "#3a3f47",
"ring-bg": "#1e5a52", "ring-on": "#b4f1e6", "ring-track": "#2b6e65", "ring-thumb": "#3f9a7f", "on-ring-thumb": "#ffffff",
"timer-track": "#3a3d43", "timer-progress": "#7ec4a8",
"night-text": "#c4433a", "night-bg": "#000000"
"dark-nav": "#1d2024",
"dark-nav-indicator": "#3a3f47",
"ring-bg": "#1e5a52",
"ring-on": "#b4f1e6",
"ring-track": "#2b6e65",
"ring-thumb": "#3f9a7f",
"on-ring-thumb": "#ffffff",
"timer-track": "#3a3d43",
"timer-progress": "#7ec4a8",
"night-text": "#c4433a",
"night-bg": "#000000",
"sunrise-night": "#0d0710",
"sunrise-ember": "#5a1a10",
"sunrise-day": "#ffd9a0",
"sunrise-sun": "#fff3d6"
},
"pairs": [
["text","bg"],["text","card"],["text","card-high"],["text-muted","bg"],["text-muted","card"],["text-off","card"],
["on-accent","accent"],["on-accent-container","accent-container"],["on-fab","fab"],["on-chip-on","chip-on"],
["on-secondary-pill","secondary-pill"],["on-stop","stop"],["accent","card"],["danger","card"],["text","nav"],["text","nav-indicator"],
["border-input","surface","ui"],
["dark-text","dark-bg"],["dark-text","dark-card"],["dark-text","dark-card-high"],["dark-text-muted","dark-bg"],["dark-text-muted","dark-card"],["dark-text-off","dark-card"],
["dark-on-accent","dark-accent"],["dark-on-accent-container","dark-accent-container"],["dark-on-fab","dark-fab"],["dark-on-chip-on","dark-chip-on"],
["dark-on-secondary-pill","dark-secondary-pill"],["dark-on-stop","dark-stop"],["dark-accent","dark-card"],["dark-danger","dark-card"],
["dark-text","dark-nav"],["dark-text","dark-nav-indicator"],["dark-border-input","dark-card","ui"],
["ring-on","ring-bg"],["ring-on","ring-track"],["on-ring-thumb","ring-thumb","large"],
["timer-progress","dark-card","ui"],["night-text","night-bg","large"]
[
"text",
"bg"
],
[
"text",
"card"
],
[
"text",
"card-high"
],
[
"text-muted",
"bg"
],
[
"text-muted",
"card"
],
[
"text-off",
"card"
],
[
"on-accent",
"accent"
],
[
"on-accent-container",
"accent-container"
],
[
"on-fab",
"fab"
],
[
"on-chip-on",
"chip-on"
],
[
"on-secondary-pill",
"secondary-pill"
],
[
"on-stop",
"stop"
],
[
"accent",
"card"
],
[
"danger",
"card"
],
[
"text",
"nav"
],
[
"text",
"nav-indicator"
],
[
"border-input",
"surface",
"ui"
],
[
"dark-text",
"dark-bg"
],
[
"dark-text",
"dark-card"
],
[
"dark-text",
"dark-card-high"
],
[
"dark-text-muted",
"dark-bg"
],
[
"dark-text-muted",
"dark-card"
],
[
"dark-text-off",
"dark-card"
],
[
"dark-on-accent",
"dark-accent"
],
[
"dark-on-accent-container",
"dark-accent-container"
],
[
"dark-on-fab",
"dark-fab"
],
[
"dark-on-chip-on",
"dark-chip-on"
],
[
"dark-on-secondary-pill",
"dark-secondary-pill"
],
[
"dark-on-stop",
"dark-stop"
],
[
"dark-accent",
"dark-card"
],
[
"dark-danger",
"dark-card"
],
[
"dark-text",
"dark-nav"
],
[
"dark-text",
"dark-nav-indicator"
],
[
"dark-border-input",
"dark-card",
"ui"
],
[
"ring-on",
"ring-bg"
],
[
"ring-on",
"ring-track"
],
[
"on-ring-thumb",
"ring-thumb",
"large"
],
[
"timer-progress",
"dark-card",
"ui"
],
[
"night-text",
"night-bg",
"large"
]
],
"font": {
"sans": "Roboto Flex (OFL), system fallback",
@@ -48,21 +233,88 @@
"mono": "tabular figures (fontFeatureSettings tnum) instead of a mono font for the stopwatch"
},
"type": {
"label-sm": {"size": 12, "line": 16, "weight": 500},
"label": {"size": 14, "line": 20, "weight": 500},
"body-sm": {"size": 14, "line": 20, "weight": 400},
"body": {"size": 16, "line": 24, "weight": 400},
"title": {"size": 22, "line": 28, "weight": 400},
"headline": {"size": 28, "line": 36, "weight": 400},
"alarm-time": {"size": 56, "line": 60, "weight": 600},
"alarm-ampm": {"size": 20, "line": 24, "weight": 600},
"timer-time": {"size": 44, "line": 48, "weight": 400},
"clock-big": {"size": 88, "line": 92, "weight": 400},
"stopwatch": {"size": 72, "line": 76, "weight": 500},
"ringing": {"size": 104, "line": 108, "weight": 400}
"label-sm": {
"size": 12,
"line": 16,
"weight": 500
},
"label": {
"size": 14,
"line": 20,
"weight": 500
},
"body-sm": {
"size": 14,
"line": 20,
"weight": 400
},
"body": {
"size": 16,
"line": 24,
"weight": 400
},
"title": {
"size": 22,
"line": 28,
"weight": 400
},
"headline": {
"size": 28,
"line": 36,
"weight": 400
},
"alarm-time": {
"size": 56,
"line": 60,
"weight": 600
},
"alarm-ampm": {
"size": 20,
"line": 24,
"weight": 600
},
"timer-time": {
"size": 44,
"line": 48,
"weight": 400
},
"clock-big": {
"size": 88,
"line": 92,
"weight": 400
},
"stopwatch": {
"size": 72,
"line": 76,
"weight": 500
},
"ringing": {
"size": 104,
"line": 108,
"weight": 400
}
},
"space": [
0,
2,
4,
8,
12,
16,
20,
24,
32,
48
],
"radius": {
"xs": 8,
"sm": 12,
"md": 16,
"lg": 24,
"card": 28,
"pill": 999,
"nav-indicator": 16
},
"space": [0, 2, 4, 8, 12, 16, 20, 24, 32, 48],
"radius": {"xs": 8, "sm": 12, "md": 16, "lg": 24, "card": 28, "pill": 999, "nav-indicator": 16},
"shape": {
"fab": "circle 96dp (large, centred above the nav bar)",
"stopwatch-primary": "pill 72dp tall, full width",
@@ -72,7 +324,14 @@
"motion": {
"card-expand": "spring dampingRatio 0.85 stiffness 380",
"slide-act": "spring back dampingRatio 0.7",
"fast": "120ms", "base": "250ms"
"fast": "120ms",
"base": "250ms"
},
"layout": {"gutter": 16, "card-gap": 8, "nav-height": 80, "fab-bottom": 16, "card-padding": 20}
"layout": {
"gutter": 16,
"card-gap": 8,
"nav-height": 80,
"fab-bottom": 16,
"card-padding": 20
}
}
+4 -4
View File
@@ -24,14 +24,14 @@ Missed alarm notification,notifications,should,yes,yes,
Snoozed notification with dismiss,notifications,must,yes,yes,
World clock: local time and date,clock,must,yes,yes,
World clock: add city via search,clock,must,yes,yes,
World clock: reorder and swipe to delete,clock,should,yes,partial,swipe to delete only
World clock: reorder and swipe to delete,clock,should,yes,yes,"long-press drag; verified on device, order persists"
Analog or digital clock style,clock,should,yes,yes,
Show seconds,clock,could,yes,yes,
Automatic home clock when travelling,clock,could,yes,no,
Timer keypad h m s,timer,must,yes,yes,
Run several timers at once,timer,must,yes,yes,
Timer ring progress +1:00 pause reset delete,timer,must,yes,yes,
Timer labels,timer,could,yes,partial,stored and shown; no UI to edit yet
Timer labels,timer,could,yes,yes,tap the title to rename
Timer runs in background with notification,timer,must,yes,yes,
Timer ringing screen,timer,must,yes,yes,
Timer sound and vibrate settings,settings,should,yes,partial,vibrate setting only; sound uses default
@@ -40,8 +40,8 @@ Stopwatch laps,stopwatch,must,yes,yes,
Stopwatch running notification,stopwatch,should,yes,yes,
Bedtime schedule with wake-up alarm,bedtime,should,yes,yes,
Bedtime reminder notification,bedtime,should,yes,yes,
Sunrise alarm (brightening screen),bedtime,could,yes,no,
Sleep sounds with timer,bedtime,could,yes,no,own CC0 sounds
Sunrise alarm (brightening screen),bedtime,could,yes,partial,scheduled 15 min before the bedtime wake alarm via full-screen notification; full-screen launch not confirmed on device (no keyguard during test)
Sleep sounds with timer,bedtime,could,yes,yes,"own generated sounds (rain, waves, brown noise), sleep timer with 20 s fade-out; verified playing on device"
Screensaver / bedside clock with night mode,display,could,yes,no,DreamService
Digital clock widget,widgets,must,yes,yes,
Analog clock widget,widgets,should,yes,yes,
1 feature area priority original clone notes
24 Snoozed notification with dismiss notifications must yes yes
25 World clock: local time and date clock must yes yes
26 World clock: add city via search clock must yes yes
27 World clock: reorder and swipe to delete clock should yes partial yes swipe to delete only long-press drag; verified on device, order persists
28 Analog or digital clock style clock should yes yes
29 Show seconds clock could yes yes
30 Automatic home clock when travelling clock could yes no
31 Timer keypad h m s timer must yes yes
32 Run several timers at once timer must yes yes
33 Timer ring progress +1:00 pause reset delete timer must yes yes
34 Timer labels timer could yes partial yes stored and shown; no UI to edit yet tap the title to rename
35 Timer runs in background with notification timer must yes yes
36 Timer ringing screen timer must yes yes
37 Timer sound and vibrate settings settings should yes partial vibrate setting only; sound uses default
40 Stopwatch running notification stopwatch should yes yes
41 Bedtime schedule with wake-up alarm bedtime should yes yes
42 Bedtime reminder notification bedtime should yes yes
43 Sunrise alarm (brightening screen) bedtime could yes no partial scheduled 15 min before the bedtime wake alarm via full-screen notification; full-screen launch not confirmed on device (no keyguard during test)
44 Sleep sounds with timer bedtime could yes no yes own CC0 sounds own generated sounds (rain, waves, brown noise), sleep timer with 20 s fade-out; verified playing on device
45 Screensaver / bedside clock with night mode display could yes no DreamService
46 Digital clock widget widgets must yes yes
47 Analog clock widget widgets should yes yes
+2 -2
View File
@@ -16,8 +16,8 @@ android {
applicationId = "app.uhr"
minSdk = 29
targetSdk = 36
versionCode = 1
versionName = "0.1.0"
versionCode = 4
versionName = "0.2.0"
}
// Release key lives outside the repo; without it, release builds fall back to the debug key (local only).
@@ -13,6 +13,15 @@ class DebugAlarmActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Graph.init(this)
if (intent.getBooleanExtra("sunrise", false)) {
// through the real path: AlarmManager -> receiver -> full-screen notification, 20 s from now
Graph.repo.scheduler.scheduleSunrise(System.currentTimeMillis() + app.uhr.alarm.SunriseActivity.DURATION_MS + 20_000L)
finish(); return
}
if (intent.getBooleanExtra("cities", false)) {
runBlocking { app.uhr.data.Zones.search("").let { } ; listOf("Tokyo", "New_York", "London").forEach { c -> app.uhr.data.Zones.search(c.replace('_', ' ')).firstOrNull()?.let { Graph.repo.addCity(it) } } }
finish(); return
}
val timerS = intent.getIntExtra("timer_seconds", 0)
if (timerS > 0) {
val id = runBlocking { Graph.repo.startTimer(timerS * 1000L, "Testtimer") }
+3
View File
@@ -37,6 +37,9 @@
android:turnScreenOn="true"
android:taskAffinity="" />
<activity android:name=".alarm.SunriseActivity" android:exported="false" android:excludeFromRecents="true"
android:launchMode="singleInstance" android:showWhenLocked="true" android:turnScreenOn="true" android:taskAffinity="" />
<service android:name=".alarm.SleepSoundService" android:exported="false" android:foregroundServiceType="mediaPlayback" />
<service android:name=".alarm.RingingService" android:exported="false" android:foregroundServiceType="mediaPlayback" />
<receiver android:name=".alarm.AlarmReceiver" android:exported="false" android:directBootAware="false">
@@ -164,6 +164,7 @@ private fun ClockApp(settings: Settings, requestedTab: Int) {
override fun reset(id: Long) { scope.launch { repo.resetTimer(id) } }
override fun delete(id: Long) { scope.launch { repo.deleteTimer(id) } }
override fun stopRinging() { RingingService.stop(context) }
override fun rename(id: Long, label: String) { scope.launch { repo.setTimerLabel(id, label) } }
}
}
@@ -209,7 +210,7 @@ private fun ClockApp(settings: Settings, requestedTab: Int) {
) { pad ->
when (tab) {
0 -> AlarmScreen(alarms, settings, alarmActions, snackbar, pad)
1 -> ClockScreen(settings, cities, next, pad, onDeleteCity = { c -> scope.launch { repo.deleteCity(c.id) } })
1 -> ClockScreen(settings, cities, next, pad, onDeleteCity = { c -> scope.launch { repo.deleteCity(c.id) } }, onReorder = { ids -> scope.launch { repo.reorderCities(ids) } })
2 -> {
if (keypad && timers.isNotEmpty()) BackHandler { keypad = false; digits = "" }
TimerScreen(timers, keypad, digits, { digits = it }, timerActions, pad)
@@ -31,12 +31,13 @@ class AlarmReceiver : BroadcastReceiver() {
val repo = Graph.repo
when (action) {
Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_LOCKED_BOOT_COMPLETED, Intent.ACTION_TIME_CHANGED, "android.intent.action.TIME_SET",
Intent.ACTION_TIMEZONE_CHANGED, Intent.ACTION_MY_PACKAGE_REPLACED -> repo.scheduler.rescheduleAll()
Intent.ACTION_TIMEZONE_CHANGED, Intent.ACTION_MY_PACKAGE_REPLACED -> { repo.scheduler.rescheduleAll(); repo.scheduleBedtime() }
ACTION_UPCOMING -> repo.alarm(id)?.let { a -> repo.instance(id)?.let { Notifications.upcoming(context, a, it.fireAt) } }
ACTION_DISMISS_NEXT -> repo.dismissNext(id)
ACTION_SNOOZE_DISMISS -> repo.finishAlarm(id)
ACTION_TIMER_PAUSE -> repo.pauseTimer(id)
ACTION_TIMER_ADD_MINUTE -> repo.addMinute(id)
ACTION_SUNRISE -> Notifications.sunrise(context, id)
ACTION_BEDTIME -> { Notifications.bedtime(context); repo.scheduleBedtime() }
}
}
@@ -51,6 +52,7 @@ class AlarmReceiver : BroadcastReceiver() {
const val ACTION_TIMER_PAUSE = "app.uhr.TIMER_PAUSE"
const val ACTION_TIMER_ADD_MINUTE = "app.uhr.TIMER_ADD_MINUTE"
const val ACTION_BEDTIME = "app.uhr.BEDTIME"
const val ACTION_SUNRISE = "app.uhr.SUNRISE"
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
}
}
@@ -32,6 +32,8 @@ object Notifications {
const val ID_STOPWATCH = 2
const val ID_RINGING = 3
const val ID_BEDTIME = 4
const val ID_SLEEP = 5
const val ID_SUNRISE = 6
private const val ALARM_BASE = 1000
fun createChannels(context: Context) {
@@ -147,6 +149,26 @@ object Notifications {
)
}
/** Full-screen intent is the only way to bring the sunrise screen up from the background. */
fun sunrise(context: Context, fireAt: Long) {
if (!allowed(context)) return
val full = PendingIntent.getActivity(
context, 7, Intent(context, SunriseActivity::class.java).putExtra(SunriseActivity.EXTRA_FIRE_AT, fireAt)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NO_USER_ACTION),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
// an update of an existing notification does not fire its full-screen intent again
nm(context).cancel(ID_SUNRISE)
nm(context).notify(
ID_SUNRISE,
NotificationCompat.Builder(context, CH_RINGING).setSmallIcon(R.drawable.ic_stat_alarm)
.setContentTitle(context.getString(R.string.sunrise_running)).setSilent(true)
.setCategory(NotificationCompat.CATEGORY_ALARM).setPriority(NotificationCompat.PRIORITY_MAX)
.setFullScreenIntent(full, true).setContentIntent(full).setTimeoutAfter(SunriseActivity.DURATION_MS + 60_000)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC).build(),
)
}
fun ringing(context: Context, title: String, text: String, fullScreen: PendingIntent, stop: PendingIntent, snooze: PendingIntent?): Notification =
NotificationCompat.Builder(context, CH_RINGING).setSmallIcon(R.drawable.ic_stat_alarm)
.setContentTitle(title).setContentText(text)
@@ -71,6 +71,20 @@ class Scheduler(private val context: Context, private val dao: ClockDao) {
fun scheduleBedtimeReminder(at: Long) =
am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, at, broadcast(AlarmReceiver.ACTION_BEDTIME, 0, REQ_BEDTIME))
fun scheduleSunrise(fireAt: Long) {
val at = fireAt - SunriseActivity.DURATION_MS
if (at > System.currentTimeMillis()) am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, at, sunriseIntent(fireAt))
}
fun cancelSunrise() = am.cancel(sunriseIntent(0))
/** One sunrise at a time (fixed request code); the wake-up time travels as an extra. */
private fun sunriseIntent(fireAt: Long) = PendingIntent.getBroadcast(
context, REQ_SUNRISE,
Intent(context, AlarmReceiver::class.java).setAction(AlarmReceiver.ACTION_SUNRISE).putExtra(AlarmReceiver.EXTRA_ID, fireAt),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
fun cancelBedtimeReminder() = am.cancel(broadcast(AlarmReceiver.ACTION_BEDTIME, 0, REQ_BEDTIME))
private fun ringIntent(id: Long) = broadcast(AlarmReceiver.ACTION_RING, id, REQ_RING)
@@ -89,5 +103,6 @@ class Scheduler(private val context: Context, private val dao: ClockDao) {
private const val REQ_UPCOMING = 1_000_000
private const val REQ_TIMER = 2_000_000
private const val REQ_BEDTIME = 3_000_000
private const val REQ_SUNRISE = 4_000_000
}
}
@@ -0,0 +1,109 @@
package app.uhr.alarm
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.media.AudioAttributes
import android.media.MediaPlayer
import android.net.Uri
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import app.uhr.MainActivity
import app.uhr.R
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
enum class SleepSound(val res: Int, val title: Int) {
RAIN(R.raw.sleep_rain, R.string.sound_rain),
WAVES(R.raw.sleep_waves, R.string.sound_waves),
BROWN(R.raw.sleep_brown, R.string.sound_brown),
}
data class SleepPlayback(val sound: SleepSound, val endsAt: Long)
/** Plays a looping sleep sound on the media stream and fades it out at the end of the sleep timer. */
class SleepSoundService : Service() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var player: MediaPlayer? = null
private var job: Job? = null
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_STOP) { stop(); return START_NOT_STICKY }
val sound = SleepSound.entries.getOrNull(intent?.getIntExtra(EXTRA_SOUND, 0) ?: 0) ?: SleepSound.RAIN
val minutes = intent?.getIntExtra(EXTRA_MINUTES, 30) ?: 30
val endsAt = System.currentTimeMillis() + minutes * 60_000L
ServiceCompat.startForeground(this, Notifications.ID_SLEEP, notification(sound, endsAt), ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK)
player?.release()
player = MediaPlayer().apply {
setAudioAttributes(AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA).setContentType(AudioAttributes.CONTENT_TYPE_MUSIC).build())
setDataSource(this@SleepSoundService, Uri.parse("android.resource://$packageName/${sound.res}"))
isLooping = true
prepare()
setVolume(0f, 0f)
start()
}
state.value = SleepPlayback(sound, endsAt)
job?.cancel()
job = scope.launch {
fade(0f, 1f, 3_000)
delay((endsAt - System.currentTimeMillis() - FADE_OUT_MS).coerceAtLeast(0))
fade(1f, 0f, FADE_OUT_MS)
stop()
}
return START_NOT_STICKY
}
private suspend fun fade(from: Float, to: Float, ms: Long) {
val steps = (ms / 100).toInt().coerceAtLeast(1)
for (i in 1..steps) {
val v = from + (to - from) * i / steps
runCatching { player?.setVolume(v, v) }
delay(100)
}
}
private fun notification(sound: SleepSound, endsAt: Long) =
NotificationCompat.Builder(this, Notifications.CH_RUNNING).setSmallIcon(R.drawable.ic_stat_bedtime)
.setContentTitle(getString(sound.title)).setContentText(getString(R.string.sleep_sound_running))
.setUsesChronometer(true).setChronometerCountDown(true).setWhen(endsAt).setShowWhen(true)
.setOngoing(true).setSilent(true)
.setContentIntent(PendingIntent.getActivity(this, 5, Intent(this, MainActivity::class.java).putExtra(MainActivity.EXTRA_TAB, 4), PendingIntent.FLAG_IMMUTABLE))
.addAction(0, getString(R.string.stop), PendingIntent.getService(this, 6, Intent(this, SleepSoundService::class.java).setAction(ACTION_STOP), PendingIntent.FLAG_IMMUTABLE))
.build()
private fun stop() {
job?.cancel()
player?.runCatching { stop(); release() }; player = null
state.value = null
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
stopSelf()
}
override fun onDestroy() { player?.release(); state.value = null; scope.cancel(); super.onDestroy() }
companion object {
private const val ACTION_STOP = "stop"
private const val EXTRA_SOUND = "sound"
private const val EXTRA_MINUTES = "minutes"
private const val FADE_OUT_MS = 20_000L
private val state = MutableStateFlow<SleepPlayback?>(null)
val playing: StateFlow<SleepPlayback?> get() = state
fun start(context: Context, sound: SleepSound, minutes: Int) = context.startForegroundService(
Intent(context, SleepSoundService::class.java).putExtra(EXTRA_SOUND, sound.ordinal).putExtra(EXTRA_MINUTES, minutes),
)
fun stop(context: Context) = context.startService(Intent(context, SleepSoundService::class.java).setAction(ACTION_STOP))
}
}
@@ -0,0 +1,62 @@
package app.uhr.alarm
import android.os.Bundle
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.lerp
import app.uhr.ui.theme.WColor
import kotlinx.coroutines.delay
/**
* Sunrise alarm: in the last minutes before the wake-up alarm the screen turns on over the lock screen
* and brightens slowly from a dim red glow to warm daylight. Tap to dismiss; the alarm still rings.
*/
class SunriseActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setShowWhenLocked(true)
setTurnScreenOn(true)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
val endsAt = intent.getLongExtra(EXTRA_FIRE_AT, System.currentTimeMillis() + DURATION_MS)
val startedAt = endsAt - DURATION_MS
setContent {
val progress = remember { mutableFloatStateOf(0f) }
val ringing by RingingService.ringing.collectAsState()
LaunchedEffect(Unit) {
while (true) {
val p = ((System.currentTimeMillis() - startedAt).toFloat() / DURATION_MS).coerceIn(0f, 1f)
progress.floatValue = p
// the window brightness follows the colour, from almost off to full
window.attributes = window.attributes.apply { screenBrightness = 0.01f + 0.99f * p * p }
delay(500)
}
}
// the ringing screen takes over when the alarm starts
LaunchedEffect(ringing) { if (ringing != null) finish() }
val p = progress.floatValue
Canvas(Modifier.fillMaxSize().clickable { finish() }) {
val top = lerp(WColor.sunriseNight, WColor.sunriseDay, p)
val glow = lerp(WColor.sunriseEmber, WColor.sunriseSun, p)
drawRect(Brush.verticalGradient(listOf(top, glow)))
drawCircle(Brush.radialGradient(listOf(glow.copy(alpha = .9f), glow.copy(alpha = 0f)), Offset(size.width / 2, size.height), size.width * (0.4f + p)), size.width * (0.4f + p), Offset(size.width / 2, size.height))
}
}
}
companion object {
const val EXTRA_FIRE_AT = "fire_at"
const val DURATION_MS = 15 * 60_000L
}
}
@@ -28,10 +28,11 @@ class ClockRepo(private val context: Context, private val dao: ClockDao, val pre
suspend fun save(alarm: Alarm): Long = lock.withLock {
val stamped = alarm.copy(updatedAt = System.currentTimeMillis())
val id = if (alarm.id == 0L) dao.insert(stamped) else stamped.id.also { dao.update(stamped) }
dao.alarm(id)?.let { scheduler.reschedule(it) }
val saved = dao.alarm(id)
saved?.let { scheduler.reschedule(it) }
updateClockWidgets(context)
id
}
}.also { id -> if (dao.alarm(id)?.isBedtimeWake == true) scheduleBedtime() }
suspend fun delete(alarm: Alarm) = lock.withLock {
scheduler.cancel(alarm.id)
@@ -182,8 +183,15 @@ class ClockRepo(private val context: Context, private val dao: ClockDao, val pre
scheduleBedtime()
}
suspend fun bedtimeAlarmFireAt(): Long? {
val a = dao.bedtimeAlarm()?.takeIf { it.enabled } ?: return null
return dao.instance(a.id)?.takeIf { it.state == InstanceState.SCHEDULED }?.fireAt
}
suspend fun scheduleBedtime() {
val b = prefs.bedtime.first()
scheduler.cancelSunrise()
if (b.enabled && b.sunrise) bedtimeAlarmFireAt()?.let { scheduler.scheduleSunrise(it) }
scheduler.cancelBedtimeReminder()
if (!b.enabled || b.reminderMin < 0) return
val reminder = Alarm(hour = b.bedHour, minute = b.bedMinute, days = b.days)
+4 -2
View File
@@ -55,6 +55,7 @@ data class Bedtime(
val wakeMinute: Int = 0,
val days: Int = Alarm.WEEKDAYS,
val reminderMin: Int = 15,
val sunrise: Boolean = false,
)
private val Context.store by preferencesDataStore("clock")
@@ -85,6 +86,7 @@ class Prefs(private val context: Context) {
val wakeM = intPreferencesKey("wake_m")
val bedDays = intPreferencesKey("bed_days")
val bedReminder = intPreferencesKey("bed_reminder")
val sunrise = booleanPreferencesKey("sunrise")
}
private fun Preferences.settings(): Settings {
@@ -142,14 +144,14 @@ class Prefs(private val context: Context) {
Bedtime(
enabled = p[K.bedOn] ?: d.enabled, bedHour = p[K.bedH] ?: d.bedHour, bedMinute = p[K.bedM] ?: d.bedMinute,
wakeHour = p[K.wakeH] ?: d.wakeHour, wakeMinute = p[K.wakeM] ?: d.wakeMinute,
days = p[K.bedDays] ?: d.days, reminderMin = p[K.bedReminder] ?: d.reminderMin,
days = p[K.bedDays] ?: d.days, reminderMin = p[K.bedReminder] ?: d.reminderMin, sunrise = p[K.sunrise] ?: d.sunrise,
)
}
suspend fun setBedtime(b: Bedtime) {
context.store.edit { p ->
p[K.bedOn] = b.enabled; p[K.bedH] = b.bedHour; p[K.bedM] = b.bedMinute; p[K.wakeH] = b.wakeHour
p[K.wakeM] = b.wakeMinute; p[K.bedDays] = b.days; p[K.bedReminder] = b.reminderMin
p[K.wakeM] = b.wakeMinute; p[K.bedDays] = b.days; p[K.bedReminder] = b.reminderMin; p[K.sunrise] = b.sunrise
}
}
}
@@ -8,6 +8,9 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.VisibilityThreshold
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@@ -36,6 +39,7 @@ import androidx.compose.material.icons.outlined.NotificationsActive
import androidx.compose.material.icons.outlined.PauseCircle
import androidx.compose.material.icons.outlined.Vibration
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.GraphicEq
import androidx.compose.material.icons.rounded.ExpandLess
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.material3.AlertDialog
@@ -93,6 +97,12 @@ import java.time.LocalDate
import java.time.ZoneId
import java.time.ZoneOffset
/** One spring for everything that moves when a card opens, so size, content and neighbours stay in step. */
private val expandSpring = spring<Float>(dampingRatio = 0.9f, stiffness = 500f)
private fun androidx.compose.animation.core.SpringSpec<Float>.toIntOffset() =
spring(dampingRatio, stiffness, androidx.compose.ui.unit.IntOffset.VisibilityThreshold)
private val expandSpringSize = spring(0.9f, 500f, androidx.compose.ui.unit.IntSize.VisibilityThreshold)
/** Actions the alarm screen needs; MainActivity passes the repository, tests pass fakes. */
interface AlarmActions {
fun save(alarm: Alarm)
@@ -131,12 +141,12 @@ fun AlarmScreen(
) {
if (bedtime.isNotEmpty()) item(key = "h-bed") { SectionHeader(stringResource(R.string.wake_up)) }
items(bedtime, key = { it.alarm.id }) { a ->
AlarmCard(a, settings, expanded == a.alarm.id, { expanded = if (expanded == a.alarm.id) null else a.alarm.id }, actions, onDelete = null)
AlarmCard(Modifier.animateItem(placementSpec = expandSpring.toIntOffset()), a, settings, expanded == a.alarm.id, { expanded = if (expanded == a.alarm.id) null else a.alarm.id }, actions, onDelete = null)
}
if (bedtime.isNotEmpty() && others.isNotEmpty()) item(key = "h-other") { SectionHeader(stringResource(R.string.other_alarms)) }
items(others, key = { it.alarm.id }) { a ->
AlarmCard(
a, settings, expanded == a.alarm.id, { expanded = if (expanded == a.alarm.id) null else a.alarm.id }, actions,
Modifier.animateItem(placementSpec = expandSpring.toIntOffset()), a, settings, expanded == a.alarm.id, { expanded = if (expanded == a.alarm.id) null else a.alarm.id }, actions,
onDelete = {
actions.delete(a.alarm)
expanded = null
@@ -157,6 +167,7 @@ private fun SectionHeader(text: String) =
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AlarmCard(
modifier: Modifier,
item: AlarmWithInstance,
settings: Settings,
expanded: Boolean,
@@ -181,21 +192,23 @@ private fun AlarmCard(
else -> ""
}
Column(
Modifier.fillMaxWidth().clip(RoundedCornerShape(WRadius.card)).background(scheme.surfaceContainerLow)
.clickable(onClick = onToggleExpand).padding(WSpace.s20).animateContentSize(spring(dampingRatio = .85f, stiffness = 380f))
modifier.fillMaxWidth().clip(RoundedCornerShape(WRadius.card)).background(scheme.surfaceContainerLow)
.clickable(onClick = onToggleExpand).padding(WSpace.s20)
.semantics(mergeDescendants = !expanded) {
contentDescription = listOf(a.label, Fmt.time(a.hour, a.minute), status).filter { it.isNotBlank() }.joinToString(", ")
stateDescription = res.getString(if (a.enabled) R.string.on else R.string.off)
},
) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (expanded) {
LabelField(a.label) { actions.save(a.copy(label = it)) }
} else {
Text(a.label, style = WType.label, color = scheme.onSurfaceVariant, modifier = Modifier.weight(1f))
val rotation by androidx.compose.animation.core.animateFloatAsState(if (expanded) 180f else 0f, expandSpring, label = "chevron")
Row(Modifier.height(48.dp), verticalAlignment = Alignment.CenterVertically) {
androidx.compose.animation.Crossfade(expanded, Modifier.weight(1f), animationSpec = tween(180), label = "label") { open ->
Row(Modifier.fillMaxWidth().height(48.dp), verticalAlignment = Alignment.CenterVertically) {
if (open) LabelField(a.label) { actions.save(a.copy(label = it)) }
else Text(a.label, style = WType.label, color = scheme.onSurfaceVariant, modifier = Modifier.weight(1f))
}
}
Box(Modifier.size(28.dp).clip(CircleShape).background(scheme.surfaceContainerHigh), contentAlignment = Alignment.Center) {
Icon(if (expanded) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore, null, Modifier.size(20.dp), tint = scheme.onSurface)
Icon(Icons.Rounded.ExpandMore, null, Modifier.size(20.dp).graphicsLayer { rotationZ = rotation }, tint = scheme.onSurface)
}
}
Spacer(Modifier.height(WSpace.s8))
@@ -208,7 +221,11 @@ private fun AlarmCard(
Text(status, style = WType.bodySm, color = scheme.onSurface, modifier = Modifier.weight(1f))
Switch(a.enabled, onCheckedChange = { actions.save(a.copy(enabled = it, skipUntil = null)) })
}
AnimatedVisibility(expanded) {
AnimatedVisibility(
expanded,
enter = androidx.compose.animation.expandVertically(expandSpringSize, expandFrom = Alignment.Top) + androidx.compose.animation.fadeIn(tween(220, delayMillis = 60)),
exit = androidx.compose.animation.shrinkVertically(expandSpringSize, shrinkTowards = Alignment.Top) + androidx.compose.animation.fadeOut(tween(120)),
) {
Column {
Spacer(Modifier.height(WSpace.s12))
DayChips(a, settings.weekStart) { actions.save(it) }
@@ -247,17 +264,23 @@ private fun AlarmCard(
@Composable
private fun androidx.compose.foundation.layout.RowScope.LabelField(value: String, onDone: (String) -> Unit) {
var text by remember(value) { mutableStateOf(value) }
TextField(
androidx.compose.foundation.text.BasicTextField(
text, { text = it }, Modifier.weight(1f),
placeholder = { Text(stringResource(R.string.add_label)) },
leadingIcon = { Icon(Icons.Outlined.Label, null) },
singleLine = true, textStyle = WType.body,
textStyle = WType.body.copy(color = MaterialTheme.colorScheme.onSurface),
cursorBrush = androidx.compose.ui.graphics.SolidColor(MaterialTheme.colorScheme.primary),
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = androidx.compose.foundation.text.KeyboardActions(onDone = { onDone(text.trim()) }),
colors = TextFieldDefaults.colors(
focusedContainerColor = androidx.compose.ui.graphics.Color.Transparent, unfocusedContainerColor = androidx.compose.ui.graphics.Color.Transparent,
unfocusedIndicatorColor = androidx.compose.ui.graphics.Color.Transparent,
),
decorationBox = { inner ->
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Outlined.Label, null, Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.width(WSpace.s12))
Box {
if (text.isEmpty()) Text(stringResource(R.string.add_label), style = WType.body, color = MaterialTheme.colorScheme.onSurfaceVariant)
inner()
}
}
},
)
androidx.compose.runtime.DisposableEffect(Unit) { onDispose { if (text.trim() != value) onDone(text.trim()) } }
}
@@ -306,22 +329,49 @@ fun soundName(uri: String?): String {
}
}
/** S03: M3 time picker with dial/keyboard switch. */
/**
* S03: M3 time picker with dial/keyboard switch. The Compose dial has no haptics; like the original we tick
* on every value the hand passes and give a firmer click when it settles after release.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TimeDialog(hour: Int, minute: Int, onDismiss: () -> Unit, onPick: (Int, Int) -> Unit) {
val state = rememberTimePickerState(hour, minute, Fmt.is24h)
var keyboard by remember { mutableStateOf(false) }
val view = androidx.compose.ui.platform.LocalView.current
androidx.compose.runtime.LaunchedEffect(state) {
var last = state.hour to state.minute
androidx.compose.runtime.snapshotFlow { state.hour to state.minute }.collect { now ->
if (now != last) {
view.performHapticFeedback(android.view.HapticFeedbackConstants.CLOCK_TICK)
last = now
}
}
}
// the dial switches from hours to minutes when the hour settles: confirm that with a firmer click
androidx.compose.runtime.LaunchedEffect(state) {
var hourMode = state.selection == androidx.compose.material3.TimePickerSelectionMode.Hour
androidx.compose.runtime.snapshotFlow { state.selection }.collect { sel ->
val nowHour = sel == androidx.compose.material3.TimePickerSelectionMode.Hour
if (hourMode && !nowHour) view.performHapticFeedback(android.view.HapticFeedbackConstants.CONFIRM)
hourMode = nowHour
}
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.pick_time)) },
text = {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) {
if (keyboard) TimeInput(state) else TimePicker(state)
TextButton(onClick = { keyboard = !keyboard }) { Text(stringResource(if (keyboard) R.string.use_dial else R.string.use_keyboard)) }
}
},
confirmButton = { TextButton(onClick = { onPick(state.hour, state.minute) }) { Text(stringResource(android.R.string.ok)) } },
confirmButton = {
TextButton(onClick = {
view.performHapticFeedback(android.view.HapticFeedbackConstants.CONFIRM)
onPick(state.hour, state.minute)
}) { Text(stringResource(android.R.string.ok)) }
},
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } },
)
}
@@ -339,12 +389,14 @@ fun SoundDialog(current: String?, onDismiss: () -> Unit, onPick: (String?) -> Un
}
list
}
var preview by remember { mutableStateOf<android.media.Ringtone?>(null) }
androidx.compose.runtime.DisposableEffect(Unit) { onDispose { preview?.stop() } }
// demo on the alarm stream, like the real ring: audible in silent mode and Do Not Disturb
val preview = remember { SoundPreview(context) }
var playing by remember { mutableStateOf<String?>("none") }
androidx.compose.runtime.DisposableEffect(Unit) { onDispose { preview.stop() } }
fun play(uri: String?) {
preview?.stop()
val u = when (uri) { null -> Uri.parse("android.resource://${context.packageName}/${R.raw.alarm_default}"); Alarm.SILENT -> null; else -> Uri.parse(uri) }
preview = u?.let { RingtoneManager.getRingtone(context, it) }?.also { it.play() }
if (playing == uri && preview.isPlaying) { preview.stop(); playing = "none"; return }
playing = uri
preview.play(uri)
}
val file = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
if (uri != null) {
@@ -367,12 +419,49 @@ fun SoundDialog(current: String?, onDismiss: () -> Unit, onPick: (String?) -> Un
) {
RadioButton(selected == uri, onClick = null)
Spacer(Modifier.width(WSpace.s12))
Text(name, style = WType.body)
Text(name, style = WType.body, modifier = Modifier.weight(1f))
if (playing == uri && uri != Alarm.SILENT) Icon(Icons.Rounded.GraphicEq, stringResource(R.string.playing), tint = MaterialTheme.colorScheme.primary)
}
}
}
},
confirmButton = { TextButton(onClick = { onPick(selected) }) { Text(stringResource(android.R.string.ok)) } },
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } },
confirmButton = { TextButton(onClick = { preview.stop(); onPick(selected) }) { Text(stringResource(android.R.string.ok)) } },
dismissButton = { TextButton(onClick = { preview.stop(); onDismiss() }) { Text(stringResource(android.R.string.cancel)) } },
)
}
/** Plays a sound for a few seconds on the alarm stream at the user's alarm volume, then fades out. */
class SoundPreview(private val context: android.content.Context) {
private var player: android.media.MediaPlayer? = null
private val handler = android.os.Handler(android.os.Looper.getMainLooper())
val isPlaying: Boolean get() = runCatching { player?.isPlaying == true }.getOrDefault(false)
fun play(uri: String?) {
stop()
val u = when (uri) {
Alarm.SILENT -> return
null -> Uri.parse("android.resource://${context.packageName}/${R.raw.alarm_default}")
else -> Uri.parse(uri)
}
player = runCatching {
android.media.MediaPlayer().apply {
setAudioAttributes(android.media.AudioAttributes.Builder().setUsage(android.media.AudioAttributes.USAGE_ALARM)
.setContentType(android.media.AudioAttributes.CONTENT_TYPE_SONIFICATION).build())
setDataSource(context, u)
isLooping = true
prepare()
start()
}
}.getOrNull()
// a demo, not an alarm: stop after a few seconds
handler.postDelayed({ stop() }, DEMO_MS)
}
fun stop() {
handler.removeCallbacksAndMessages(null)
player?.runCatching { stop(); release() }
player = null
}
private companion object { const val DEMO_MS = 8_000L }
}
@@ -22,6 +22,14 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Bedtime
import androidx.compose.material.icons.outlined.NotificationsActive
import androidx.compose.material.icons.outlined.WbSunny
import androidx.compose.material.icons.outlined.WbTwilight
import androidx.compose.material.icons.outlined.Waves
import androidx.compose.material.icons.rounded.Stop
import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.platform.LocalContext
import app.uhr.alarm.SleepSound
import app.uhr.alarm.SleepSoundService
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -102,7 +110,9 @@ fun BedtimeScreen(b: Bedtime, settings: Settings, onSave: (Bedtime) -> Unit, con
{ m -> if (m < 0) stringResource(R.string.off) else if (m == 0) stringResource(R.string.at_bedtime) else stringResource(R.string.minutes_before, m) },
{ m -> onSave(b.copy(reminderMin = m)) }, Icons.Outlined.NotificationsActive)
}
item { SettingsSwitch(it, stringResource(R.string.sunrise_alarm), b.sunrise, { v -> onSave(b.copy(sunrise = v)) }, stringResource(R.string.sunrise_alarm_sub), Icons.Outlined.WbTwilight) }
}
SleepSounds()
Spacer(Modifier.height(120.dp))
}
if (pickBed) TimeDialog(b.bedHour, b.bedMinute, { pickBed = false }) { h, m -> pickBed = false; onSave(b.copy(bedHour = h, bedMinute = m)) }
@@ -122,3 +132,29 @@ private fun TimeTile(icon: androidx.compose.ui.graphics.vector.ImageVector, labe
Text(time, style = tabular(WType.timerTime))
}
}
/** Own looping sounds with a sleep timer; they fade out at the end. */
@Composable
private fun SleepSounds() {
val context = LocalContext.current
val playing by SleepSoundService.playing.collectAsState()
var sound by remember { mutableStateOf(playing?.sound ?: SleepSound.RAIN) }
var minutes by remember { mutableStateOf(30) }
SettingsGroup(stringResource(R.string.sleep_sounds)) {
item {
SettingsChoice(it, stringResource(R.string.sleep_sound), SleepSound.entries, sound, { s -> stringResource(s.title) }, { s -> sound = s }, Icons.Outlined.Waves)
}
item {
SettingsChoice(it, stringResource(R.string.sleep_timer), listOf(15, 30, 45, 60, 90), minutes, { m -> stringResource(R.string.n_minutes, m) }, { m -> minutes = m }, Icons.Outlined.Bedtime)
}
item {
val on = playing != null
app.uhr.ui.components.SettingsItem(
it, stringResource(if (on) R.string.stop else R.string.sleep_play),
if (on) stringResource(R.string.sleep_ends, app.uhr.ui.components.Fmt.clock(playing!!.endsAt)) else null,
if (on) Icons.Rounded.Stop else Icons.Rounded.PlayArrow,
onClick = { if (on) SleepSoundService.stop(context) else SleepSoundService.start(context, sound, minutes) },
)
}
}
}
@@ -40,6 +40,10 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.zIndex
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@@ -69,6 +73,7 @@ fun ClockScreen(
contentPadding: PaddingValues,
onDeleteCity: (City) -> Unit,
nowOverride: Long? = null,
onReorder: (List<Long>) -> Unit = {},
) {
val tick = remember { mutableLongStateOf(nowOverride ?: System.currentTimeMillis()) }
if (nowOverride == null) LaunchedEffect(settings.showSeconds) {
@@ -77,6 +82,11 @@ fun ClockScreen(
val now = tick.longValue
val res = LocalContext.current.resources
val scheme = MaterialTheme.colorScheme
val view = androidx.compose.ui.platform.LocalView.current
val density = androidx.compose.ui.platform.LocalDensity.current
var order by remember(cities) { mutableStateOf(cities) }
var draggedId by remember { mutableStateOf<Long?>(null) }
var dragOffset by remember { androidx.compose.runtime.mutableFloatStateOf(0f) }
LazyColumn(
Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = WSpace.s16, end = WSpace.s16, top = contentPadding.calculateTopPadding(), bottom = contentPadding.calculateBottomPadding() + 120.dp),
@@ -101,7 +111,33 @@ fun ClockScreen(
}
}
}
items(cities, key = { it.id }) { city -> CityRow(city, now, settings) { onDeleteCity(city) } }
items(order, key = { it.id }) { city ->
val dragging = draggedId == city.id
Box(
Modifier.zIndex(if (dragging) 1f else 0f)
.graphicsLayer { translationY = if (dragging) dragOffset else 0f; scaleX = if (dragging) 1.03f else 1f; scaleY = scaleX; shadowElevation = if (dragging) 12f else 0f }
.then(if (dragging) Modifier else Modifier.animateItem())
.pointerInput(city.id) {
detectDragGesturesAfterLongPress(
onDragStart = { draggedId = city.id; dragOffset = 0f; view.performHapticFeedback(android.view.HapticFeedbackConstants.LONG_PRESS) },
onDragEnd = { draggedId = null; dragOffset = 0f; onReorder(order.map { it.id }) },
onDragCancel = { draggedId = null; dragOffset = 0f; order = cities },
) { change, drag ->
change.consume()
dragOffset += drag.y
// swap with the neighbour once the card has moved past half of it
val step = size.height + with(density) { WSpace.s8.toPx() }
val i = order.indexOfFirst { it.id == city.id }
val target = (i + (dragOffset / step).toInt()).coerceIn(0, order.lastIndex)
if (target != i) {
order = order.toMutableList().apply { add(target, removeAt(i)) }
dragOffset -= (target - i) * step
view.performHapticFeedback(android.view.HapticFeedbackConstants.SEGMENT_TICK)
}
}
},
) { CityRow(city, now, settings) { onDeleteCity(city) } }
}
}
}
@@ -11,6 +11,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.outlined.AccessTime
import androidx.compose.material.icons.outlined.BatteryChargingFull
import androidx.compose.material.icons.outlined.CalendarMonth
import androidx.compose.material.icons.outlined.DarkMode
import androidx.compose.material.icons.outlined.Snooze
@@ -84,6 +85,9 @@ fun SettingsScreen(s: Settings, onChange: ((Settings) -> Settings) -> Unit, onBa
SettingsChoice(it, stringResource(R.string.theme), ThemeMode.entries, s.theme, { v -> stringResource(labels.getValue(v)) }, { v -> onChange { x -> x.copy(theme = v) } }, Icons.Outlined.DarkMode)
}
}
SettingsGroup(stringResource(R.string.charging)) {
item { app.uhr.ui.components.SettingsItem(it, stringResource(R.string.adaptive_charging), stringResource(R.string.adaptive_charging_text), Icons.Outlined.BatteryChargingFull) }
}
Spacer(Modifier.height(32.dp()))
}
}
@@ -39,6 +39,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
@@ -72,6 +73,7 @@ interface TimerActions {
fun reset(id: Long)
fun delete(id: Long)
fun stopRinging()
fun rename(id: Long, label: String) {}
}
/** S08: keypad when there is no timer (or after "+"), cards for running ones. */
@@ -109,7 +111,9 @@ private fun TimerCard(t: Timer, now: Long, actions: TimerActions) {
val label = t.label.ifBlank { stringResource(R.string.timer_of, Fmt.duration(t.durationMs)) }
Column(Modifier.fillMaxWidth().clip(RoundedCornerShape(WRadius.card)).background(scheme.surfaceContainerLow).padding(WSpace.s20)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(label, style = WType.title, modifier = Modifier.weight(1f))
var renaming by remember { androidx.compose.runtime.mutableStateOf(false) }
Text(label, style = WType.title, modifier = Modifier.weight(1f).clip(RoundedCornerShape(WRadius.xs)).clickable { renaming = true })
if (renaming) RenameDialog(t.label, { renaming = false }) { renaming = false; actions.rename(t.id, it) }
IconButton(onClick = { actions.delete(t.id) }) { Icon(Icons.Rounded.Close, stringResource(R.string.delete)) }
}
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -205,3 +209,18 @@ private fun Keypad(digits: String, onDigits: (String) -> Unit, modifier: Modifie
}
}
}
@Composable
private fun RenameDialog(current: String, onDismiss: () -> Unit, onSave: (String) -> Unit) {
var text by remember { androidx.compose.runtime.mutableStateOf(current) }
androidx.compose.material3.AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.rename_timer)) },
text = {
androidx.compose.material3.OutlinedTextField(text, { text = it.take(40) }, singleLine = true,
placeholder = { Text(stringResource(R.string.add_label)) }, modifier = Modifier.fillMaxWidth())
},
confirmButton = { androidx.compose.material3.TextButton(onClick = { onSave(text.trim()) }) { Text(stringResource(android.R.string.ok)) } },
dismissButton = { androidx.compose.material3.TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } },
)
}
@@ -37,7 +37,8 @@ private val Dark = darkColorScheme(
)
private val AppTypography = Typography(
displayLarge = WType.clockBig, headlineLarge = WType.headline, titleLarge = WType.title,
// displayLarge/headline stay M3 defaults: the time picker uses them for its hour and minute fields
titleLarge = WType.title,
bodyLarge = WType.body, bodyMedium = WType.bodySm, labelLarge = WType.label, labelMedium = WType.label, labelSmall = WType.labelSm,
)
@@ -65,6 +65,10 @@ object WColor {
val timerProgress = Color(0xFF7EC4A8)
val nightText = Color(0xFFC4433A)
val nightBg = Color(0xFF000000)
val sunriseNight = Color(0xFF0D0710)
val sunriseEmber = Color(0xFF5A1A10)
val sunriseDay = Color(0xFFFFD9A0)
val sunriseSun = Color(0xFFFFF3D6)
}
object WType {
Binary file not shown.
Binary file not shown.
Binary file not shown.
+17
View File
@@ -46,6 +46,7 @@
<string name="alarm_sound">Weckton</string>
<string name="sound_default">Standard (Morgenklang)</string>
<string name="sound_silent">Ohne Ton</string>
<string name="playing">Wird abgespielt</string>
<string name="add_own_sound">+ Eigene Audiodatei wählen</string>
<string name="pick_time">Uhrzeit wählen</string>
<string name="use_dial">Zifferblatt</string>
@@ -86,6 +87,19 @@
<string name="at_bedtime">Zur Schlafenszeit</string>
<string name="minutes_before">%1$d Min. vorher</string>
<string name="sunrise_alarm">Sonnenaufgang</string>
<string name="sunrise_alarm_sub">15 Min. vor dem Aufwachen wird der Bildschirm langsam heller</string>
<string name="sunrise_running">Sonnenaufgang läuft</string>
<string name="sleep_sounds">Einschlafgeräusche</string>
<string name="sleep_sound">Geräusch</string>
<string name="sleep_timer">Ausschalten nach</string>
<string name="sleep_play">Abspielen</string>
<string name="sleep_ends">Endet um %1$s, wird leiser</string>
<string name="sleep_sound_running">Läuft, blendet am Ende aus</string>
<string name="sound_rain">Regen</string>
<string name="sound_waves">Meeresrauschen</string>
<string name="sound_brown">Tiefes Rauschen</string>
<string name="rename_timer">Timer benennen</string>
<string name="silence_after">Automatisch stumm nach</string>
<string name="snooze_length">Schlummerdauer</string>
<string name="alarm_volume">Lautstärke</string>
@@ -103,6 +117,9 @@
<string name="theme_system">System</string>
<string name="theme_light">Hell</string>
<string name="theme_dark">Dunkel</string>
<string name="charging">Laden</string>
<string name="adaptive_charging">Adaptives Laden</string>
<string name="adaptive_charging_text">Diese App meldet jeden Wecker als „nächsten Wecker“ an das System. Ladefunktionen wie das adaptive Laden auf Pixel-Geräten richten sich danach und laden bis zum Klingeln langsam auf 100 %. Die Ladegeschwindigkeit selbst kann nur das System steuern, keine App. Auf GrapheneOS gibt es stattdessen die Ladebegrenzung auf 80 % in den Akku-Einstellungen.</string>
<string name="n_minutes">%1$d Minuten</string>
<string name="n_seconds">%1$d Sekunden</string>