Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7812f62c0f | ||
|
|
08633f918c |
@@ -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.
|
- **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.
|
- **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).
|
- **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">
|
<p align="center">
|
||||||
<img src="docs/screenshots/uhr/S01-alarms-dark.png" width="200" alt="Alarm list">
|
<img src="docs/screenshots/uhr/S01-alarms-dark.png" width="200" alt="Alarm list">
|
||||||
|
|||||||
@@ -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 | – | – |
|
| S03 Time picker | 2026-10-17 | done | – | – |
|
||||||
| S04 Sound picker | 2026-10-17 | done | – | own file not device-tested |
|
| S04 Sound picker | 2026-10-17 | done | – | own file not device-tested |
|
||||||
| S05 Pause alarm | 2026-10-17 | done | – | – |
|
| 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 | – | – |
|
| 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 | – | – |
|
| 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 | – | – |
|
| S11 Ringing | 2026-10-17 | done | – | – |
|
||||||
| S12 Timer ringing | 2026-10-17 | done | – | shares the ringing screen |
|
| S12 Timer ringing | 2026-10-17 | done | – | shares the ringing screen |
|
||||||
| S13 Settings | 2026-10-17 | done | timer sound picker, home time zone | – |
|
| S13 Settings | 2026-10-17 | done | timer sound picker, home time zone | – |
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 161 KiB After Width: | Height: | Size: 159 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 180 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 54 KiB |
@@ -65,6 +65,10 @@ object WColor {
|
|||||||
val timerProgress = Color(0xFF7EC4A8)
|
val timerProgress = Color(0xFF7EC4A8)
|
||||||
val nightText = Color(0xFFC4433A)
|
val nightText = Color(0xFFC4433A)
|
||||||
val nightBg = Color(0xFF000000)
|
val nightBg = Color(0xFF000000)
|
||||||
|
val sunriseNight = Color(0xFF0D0710)
|
||||||
|
val sunriseEmber = Color(0xFF5A1A10)
|
||||||
|
val sunriseDay = Color(0xFFFFD9A0)
|
||||||
|
val sunriseSun = Color(0xFFFFF3D6)
|
||||||
}
|
}
|
||||||
|
|
||||||
object WType {
|
object WType {
|
||||||
|
|||||||
@@ -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",
|
"_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": {
|
"color": {
|
||||||
"bg": "#f8f9ff", "surface": "#ffffff", "card": "#eef0f6", "card-high": "#e2e5ec",
|
"bg": "#f8f9ff",
|
||||||
"border": "#c3c6cf", "border-input": "#73777f",
|
"surface": "#ffffff",
|
||||||
"text": "#191c20", "text-muted": "#43474e", "text-off": "#5c6068",
|
"card": "#eef0f6",
|
||||||
"accent": "#2f5ea8", "on-accent": "#ffffff",
|
"card-high": "#e2e5ec",
|
||||||
"accent-container": "#d6e3ff", "on-accent-container": "#001b3e",
|
"border": "#c3c6cf",
|
||||||
"fab": "#a8c7fa", "on-fab": "#0b2a55",
|
"border-input": "#73777f",
|
||||||
"chip-on": "#2f5ea8", "on-chip-on": "#ffffff",
|
"text": "#191c20",
|
||||||
"secondary-pill": "#dfe2eb", "on-secondary-pill": "#191c20",
|
"text-muted": "#43474e",
|
||||||
"stop": "#ffd8e4", "on-stop": "#4a1426",
|
"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",
|
"danger": "#ba1a1a",
|
||||||
"nav": "#eceef4", "nav-indicator": "#d6e3ff",
|
"nav": "#eceef4",
|
||||||
|
"nav-indicator": "#d6e3ff",
|
||||||
"dark-bg": "#111318", "dark-surface": "#111318", "dark-card": "#1d2024", "dark-card-high": "#272a2f",
|
"dark-bg": "#111318",
|
||||||
"dark-border": "#43474e", "dark-border-input": "#8d9199",
|
"dark-surface": "#111318",
|
||||||
"dark-text": "#e2e2e9", "dark-text-muted": "#c3c6cf", "dark-text-off": "#9a9ea6",
|
"dark-card": "#1d2024",
|
||||||
"dark-accent": "#a8c7fa", "dark-on-accent": "#062e6f",
|
"dark-card-high": "#272a2f",
|
||||||
"dark-accent-container": "#244a80", "dark-on-accent-container": "#d6e3ff",
|
"dark-border": "#43474e",
|
||||||
"dark-fab": "#a8c7fa", "dark-on-fab": "#062e6f",
|
"dark-border-input": "#8d9199",
|
||||||
"dark-chip-on": "#a8c7fa", "dark-on-chip-on": "#062e6f",
|
"dark-text": "#e2e2e9",
|
||||||
"dark-secondary-pill": "#3a3d43", "dark-on-secondary-pill": "#e2e2e9",
|
"dark-text-muted": "#c3c6cf",
|
||||||
"dark-stop": "#f2b8c9", "dark-on-stop": "#4a1426",
|
"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-danger": "#ffb4ab",
|
||||||
"dark-nav": "#1d2024", "dark-nav-indicator": "#3a3f47",
|
"dark-nav": "#1d2024",
|
||||||
|
"dark-nav-indicator": "#3a3f47",
|
||||||
"ring-bg": "#1e5a52", "ring-on": "#b4f1e6", "ring-track": "#2b6e65", "ring-thumb": "#3f9a7f", "on-ring-thumb": "#ffffff",
|
"ring-bg": "#1e5a52",
|
||||||
"timer-track": "#3a3d43", "timer-progress": "#7ec4a8",
|
"ring-on": "#b4f1e6",
|
||||||
"night-text": "#c4433a", "night-bg": "#000000"
|
"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": [
|
"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"],
|
"text",
|
||||||
["on-secondary-pill","secondary-pill"],["on-stop","stop"],["accent","card"],["danger","card"],["text","nav"],["text","nav-indicator"],
|
"bg"
|
||||||
["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"],
|
"text",
|
||||||
["dark-on-secondary-pill","dark-secondary-pill"],["dark-on-stop","dark-stop"],["dark-accent","dark-card"],["dark-danger","dark-card"],
|
"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",
|
||||||
|
"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": {
|
"font": {
|
||||||
"sans": "Roboto Flex (OFL), system fallback",
|
"sans": "Roboto Flex (OFL), system fallback",
|
||||||
@@ -48,21 +233,88 @@
|
|||||||
"mono": "tabular figures (fontFeatureSettings tnum) instead of a mono font for the stopwatch"
|
"mono": "tabular figures (fontFeatureSettings tnum) instead of a mono font for the stopwatch"
|
||||||
},
|
},
|
||||||
"type": {
|
"type": {
|
||||||
"label-sm": {"size": 12, "line": 16, "weight": 500},
|
"label-sm": {
|
||||||
"label": {"size": 14, "line": 20, "weight": 500},
|
"size": 12,
|
||||||
"body-sm": {"size": 14, "line": 20, "weight": 400},
|
"line": 16,
|
||||||
"body": {"size": 16, "line": 24, "weight": 400},
|
"weight": 500
|
||||||
"title": {"size": 22, "line": 28, "weight": 400},
|
},
|
||||||
"headline": {"size": 28, "line": 36, "weight": 400},
|
"label": {
|
||||||
"alarm-time": {"size": 56, "line": 60, "weight": 600},
|
"size": 14,
|
||||||
"alarm-ampm": {"size": 20, "line": 24, "weight": 600},
|
"line": 20,
|
||||||
"timer-time": {"size": 44, "line": 48, "weight": 400},
|
"weight": 500
|
||||||
"clock-big": {"size": 88, "line": 92, "weight": 400},
|
},
|
||||||
"stopwatch": {"size": 72, "line": 76, "weight": 500},
|
"body-sm": {
|
||||||
"ringing": {"size": 104, "line": 108, "weight": 400}
|
"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": {
|
"shape": {
|
||||||
"fab": "circle 96dp (large, centred above the nav bar)",
|
"fab": "circle 96dp (large, centred above the nav bar)",
|
||||||
"stopwatch-primary": "pill 72dp tall, full width",
|
"stopwatch-primary": "pill 72dp tall, full width",
|
||||||
@@ -72,7 +324,14 @@
|
|||||||
"motion": {
|
"motion": {
|
||||||
"card-expand": "spring dampingRatio 0.85 stiffness 380",
|
"card-expand": "spring dampingRatio 0.85 stiffness 380",
|
||||||
"slide-act": "spring back dampingRatio 0.7",
|
"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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -24,14 +24,14 @@ Missed alarm notification,notifications,should,yes,yes,
|
|||||||
Snoozed notification with dismiss,notifications,must,yes,yes,
|
Snoozed notification with dismiss,notifications,must,yes,yes,
|
||||||
World clock: local time and date,clock,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: 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,
|
Analog or digital clock style,clock,should,yes,yes,
|
||||||
Show seconds,clock,could,yes,yes,
|
Show seconds,clock,could,yes,yes,
|
||||||
Automatic home clock when travelling,clock,could,yes,no,
|
Automatic home clock when travelling,clock,could,yes,no,
|
||||||
Timer keypad h m s,timer,must,yes,yes,
|
Timer keypad h m s,timer,must,yes,yes,
|
||||||
Run several timers at once,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 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 runs in background with notification,timer,must,yes,yes,
|
||||||
Timer ringing screen,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
|
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,
|
Stopwatch running notification,stopwatch,should,yes,yes,
|
||||||
Bedtime schedule with wake-up alarm,bedtime,should,yes,yes,
|
Bedtime schedule with wake-up alarm,bedtime,should,yes,yes,
|
||||||
Bedtime reminder notification,bedtime,should,yes,yes,
|
Bedtime reminder notification,bedtime,should,yes,yes,
|
||||||
Sunrise alarm (brightening screen),bedtime,could,yes,no,
|
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,no,own CC0 sounds
|
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
|
Screensaver / bedside clock with night mode,display,could,yes,no,DreamService
|
||||||
Digital clock widget,widgets,must,yes,yes,
|
Digital clock widget,widgets,must,yes,yes,
|
||||||
Analog clock widget,widgets,should,yes,yes,
|
Analog clock widget,widgets,should,yes,yes,
|
||||||
|
|||||||
|
@@ -16,8 +16,8 @@ android {
|
|||||||
applicationId = "app.uhr"
|
applicationId = "app.uhr"
|
||||||
minSdk = 29
|
minSdk = 29
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 2
|
versionCode = 4
|
||||||
versionName = "0.1.1"
|
versionName = "0.2.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Release key lives outside the repo; without it, release builds fall back to the debug key (local only).
|
// 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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
Graph.init(this)
|
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)
|
val timerS = intent.getIntExtra("timer_seconds", 0)
|
||||||
if (timerS > 0) {
|
if (timerS > 0) {
|
||||||
val id = runBlocking { Graph.repo.startTimer(timerS * 1000L, "Testtimer") }
|
val id = runBlocking { Graph.repo.startTimer(timerS * 1000L, "Testtimer") }
|
||||||
|
|||||||
@@ -37,6 +37,9 @@
|
|||||||
android:turnScreenOn="true"
|
android:turnScreenOn="true"
|
||||||
android:taskAffinity="" />
|
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" />
|
<service android:name=".alarm.RingingService" android:exported="false" android:foregroundServiceType="mediaPlayback" />
|
||||||
|
|
||||||
<receiver android:name=".alarm.AlarmReceiver" android:exported="false" android:directBootAware="false">
|
<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 reset(id: Long) { scope.launch { repo.resetTimer(id) } }
|
||||||
override fun delete(id: Long) { scope.launch { repo.deleteTimer(id) } }
|
override fun delete(id: Long) { scope.launch { repo.deleteTimer(id) } }
|
||||||
override fun stopRinging() { RingingService.stop(context) }
|
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 ->
|
) { pad ->
|
||||||
when (tab) {
|
when (tab) {
|
||||||
0 -> AlarmScreen(alarms, settings, alarmActions, snackbar, pad)
|
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 -> {
|
2 -> {
|
||||||
if (keypad && timers.isNotEmpty()) BackHandler { keypad = false; digits = "" }
|
if (keypad && timers.isNotEmpty()) BackHandler { keypad = false; digits = "" }
|
||||||
TimerScreen(timers, keypad, digits, { digits = it }, timerActions, pad)
|
TimerScreen(timers, keypad, digits, { digits = it }, timerActions, pad)
|
||||||
|
|||||||
@@ -31,12 +31,13 @@ class AlarmReceiver : BroadcastReceiver() {
|
|||||||
val repo = Graph.repo
|
val repo = Graph.repo
|
||||||
when (action) {
|
when (action) {
|
||||||
Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_LOCKED_BOOT_COMPLETED, Intent.ACTION_TIME_CHANGED, "android.intent.action.TIME_SET",
|
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_UPCOMING -> repo.alarm(id)?.let { a -> repo.instance(id)?.let { Notifications.upcoming(context, a, it.fireAt) } }
|
||||||
ACTION_DISMISS_NEXT -> repo.dismissNext(id)
|
ACTION_DISMISS_NEXT -> repo.dismissNext(id)
|
||||||
ACTION_SNOOZE_DISMISS -> repo.finishAlarm(id)
|
ACTION_SNOOZE_DISMISS -> repo.finishAlarm(id)
|
||||||
ACTION_TIMER_PAUSE -> repo.pauseTimer(id)
|
ACTION_TIMER_PAUSE -> repo.pauseTimer(id)
|
||||||
ACTION_TIMER_ADD_MINUTE -> repo.addMinute(id)
|
ACTION_TIMER_ADD_MINUTE -> repo.addMinute(id)
|
||||||
|
ACTION_SUNRISE -> Notifications.sunrise(context, id)
|
||||||
ACTION_BEDTIME -> { Notifications.bedtime(context); repo.scheduleBedtime() }
|
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_PAUSE = "app.uhr.TIMER_PAUSE"
|
||||||
const val ACTION_TIMER_ADD_MINUTE = "app.uhr.TIMER_ADD_MINUTE"
|
const val ACTION_TIMER_ADD_MINUTE = "app.uhr.TIMER_ADD_MINUTE"
|
||||||
const val ACTION_BEDTIME = "app.uhr.BEDTIME"
|
const val ACTION_BEDTIME = "app.uhr.BEDTIME"
|
||||||
|
const val ACTION_SUNRISE = "app.uhr.SUNRISE"
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ object Notifications {
|
|||||||
const val ID_STOPWATCH = 2
|
const val ID_STOPWATCH = 2
|
||||||
const val ID_RINGING = 3
|
const val ID_RINGING = 3
|
||||||
const val ID_BEDTIME = 4
|
const val ID_BEDTIME = 4
|
||||||
|
const val ID_SLEEP = 5
|
||||||
|
const val ID_SUNRISE = 6
|
||||||
private const val ALARM_BASE = 1000
|
private const val ALARM_BASE = 1000
|
||||||
|
|
||||||
fun createChannels(context: Context) {
|
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 =
|
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)
|
NotificationCompat.Builder(context, CH_RINGING).setSmallIcon(R.drawable.ic_stat_alarm)
|
||||||
.setContentTitle(title).setContentText(text)
|
.setContentTitle(title).setContentText(text)
|
||||||
|
|||||||
@@ -71,6 +71,20 @@ class Scheduler(private val context: Context, private val dao: ClockDao) {
|
|||||||
fun scheduleBedtimeReminder(at: Long) =
|
fun scheduleBedtimeReminder(at: Long) =
|
||||||
am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, at, broadcast(AlarmReceiver.ACTION_BEDTIME, 0, REQ_BEDTIME))
|
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))
|
fun cancelBedtimeReminder() = am.cancel(broadcast(AlarmReceiver.ACTION_BEDTIME, 0, REQ_BEDTIME))
|
||||||
|
|
||||||
private fun ringIntent(id: Long) = broadcast(AlarmReceiver.ACTION_RING, id, REQ_RING)
|
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_UPCOMING = 1_000_000
|
||||||
private const val REQ_TIMER = 2_000_000
|
private const val REQ_TIMER = 2_000_000
|
||||||
private const val REQ_BEDTIME = 3_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 {
|
suspend fun save(alarm: Alarm): Long = lock.withLock {
|
||||||
val stamped = alarm.copy(updatedAt = System.currentTimeMillis())
|
val stamped = alarm.copy(updatedAt = System.currentTimeMillis())
|
||||||
val id = if (alarm.id == 0L) dao.insert(stamped) else stamped.id.also { dao.update(stamped) }
|
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)
|
updateClockWidgets(context)
|
||||||
id
|
id
|
||||||
}
|
}.also { id -> if (dao.alarm(id)?.isBedtimeWake == true) scheduleBedtime() }
|
||||||
|
|
||||||
suspend fun delete(alarm: Alarm) = lock.withLock {
|
suspend fun delete(alarm: Alarm) = lock.withLock {
|
||||||
scheduler.cancel(alarm.id)
|
scheduler.cancel(alarm.id)
|
||||||
@@ -182,8 +183,15 @@ class ClockRepo(private val context: Context, private val dao: ClockDao, val pre
|
|||||||
scheduleBedtime()
|
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() {
|
suspend fun scheduleBedtime() {
|
||||||
val b = prefs.bedtime.first()
|
val b = prefs.bedtime.first()
|
||||||
|
scheduler.cancelSunrise()
|
||||||
|
if (b.enabled && b.sunrise) bedtimeAlarmFireAt()?.let { scheduler.scheduleSunrise(it) }
|
||||||
scheduler.cancelBedtimeReminder()
|
scheduler.cancelBedtimeReminder()
|
||||||
if (!b.enabled || b.reminderMin < 0) return
|
if (!b.enabled || b.reminderMin < 0) return
|
||||||
val reminder = Alarm(hour = b.bedHour, minute = b.bedMinute, days = b.days)
|
val reminder = Alarm(hour = b.bedHour, minute = b.bedMinute, days = b.days)
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ data class Bedtime(
|
|||||||
val wakeMinute: Int = 0,
|
val wakeMinute: Int = 0,
|
||||||
val days: Int = Alarm.WEEKDAYS,
|
val days: Int = Alarm.WEEKDAYS,
|
||||||
val reminderMin: Int = 15,
|
val reminderMin: Int = 15,
|
||||||
|
val sunrise: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
private val Context.store by preferencesDataStore("clock")
|
private val Context.store by preferencesDataStore("clock")
|
||||||
@@ -85,6 +86,7 @@ class Prefs(private val context: Context) {
|
|||||||
val wakeM = intPreferencesKey("wake_m")
|
val wakeM = intPreferencesKey("wake_m")
|
||||||
val bedDays = intPreferencesKey("bed_days")
|
val bedDays = intPreferencesKey("bed_days")
|
||||||
val bedReminder = intPreferencesKey("bed_reminder")
|
val bedReminder = intPreferencesKey("bed_reminder")
|
||||||
|
val sunrise = booleanPreferencesKey("sunrise")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Preferences.settings(): Settings {
|
private fun Preferences.settings(): Settings {
|
||||||
@@ -142,14 +144,14 @@ class Prefs(private val context: Context) {
|
|||||||
Bedtime(
|
Bedtime(
|
||||||
enabled = p[K.bedOn] ?: d.enabled, bedHour = p[K.bedH] ?: d.bedHour, bedMinute = p[K.bedM] ?: d.bedMinute,
|
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,
|
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) {
|
suspend fun setBedtime(b: Bedtime) {
|
||||||
context.store.edit { p ->
|
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.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.AnimatedVisibility
|
||||||
import androidx.compose.animation.animateContentSize
|
import androidx.compose.animation.animateContentSize
|
||||||
import androidx.compose.animation.core.spring
|
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.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
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.PauseCircle
|
||||||
import androidx.compose.material.icons.outlined.Vibration
|
import androidx.compose.material.icons.outlined.Vibration
|
||||||
import androidx.compose.material.icons.rounded.Check
|
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.ExpandLess
|
||||||
import androidx.compose.material.icons.rounded.ExpandMore
|
import androidx.compose.material.icons.rounded.ExpandMore
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
@@ -93,6 +97,12 @@ import java.time.LocalDate
|
|||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.ZoneOffset
|
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. */
|
/** Actions the alarm screen needs; MainActivity passes the repository, tests pass fakes. */
|
||||||
interface AlarmActions {
|
interface AlarmActions {
|
||||||
fun save(alarm: Alarm)
|
fun save(alarm: Alarm)
|
||||||
@@ -131,12 +141,12 @@ fun AlarmScreen(
|
|||||||
) {
|
) {
|
||||||
if (bedtime.isNotEmpty()) item(key = "h-bed") { SectionHeader(stringResource(R.string.wake_up)) }
|
if (bedtime.isNotEmpty()) item(key = "h-bed") { SectionHeader(stringResource(R.string.wake_up)) }
|
||||||
items(bedtime, key = { it.alarm.id }) { a ->
|
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)) }
|
if (bedtime.isNotEmpty() && others.isNotEmpty()) item(key = "h-other") { SectionHeader(stringResource(R.string.other_alarms)) }
|
||||||
items(others, key = { it.alarm.id }) { a ->
|
items(others, key = { it.alarm.id }) { a ->
|
||||||
AlarmCard(
|
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 = {
|
onDelete = {
|
||||||
actions.delete(a.alarm)
|
actions.delete(a.alarm)
|
||||||
expanded = null
|
expanded = null
|
||||||
@@ -157,6 +167,7 @@ private fun SectionHeader(text: String) =
|
|||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun AlarmCard(
|
private fun AlarmCard(
|
||||||
|
modifier: Modifier,
|
||||||
item: AlarmWithInstance,
|
item: AlarmWithInstance,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
expanded: Boolean,
|
expanded: Boolean,
|
||||||
@@ -181,21 +192,23 @@ private fun AlarmCard(
|
|||||||
else -> ""
|
else -> ""
|
||||||
}
|
}
|
||||||
Column(
|
Column(
|
||||||
Modifier.fillMaxWidth().clip(RoundedCornerShape(WRadius.card)).background(scheme.surfaceContainerLow)
|
modifier.fillMaxWidth().clip(RoundedCornerShape(WRadius.card)).background(scheme.surfaceContainerLow)
|
||||||
.clickable(onClick = onToggleExpand).padding(WSpace.s20).animateContentSize(spring(dampingRatio = .85f, stiffness = 380f))
|
.clickable(onClick = onToggleExpand).padding(WSpace.s20)
|
||||||
.semantics(mergeDescendants = !expanded) {
|
.semantics(mergeDescendants = !expanded) {
|
||||||
contentDescription = listOf(a.label, Fmt.time(a.hour, a.minute), status).filter { it.isNotBlank() }.joinToString(", ")
|
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)
|
stateDescription = res.getString(if (a.enabled) R.string.on else R.string.off)
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
val rotation by androidx.compose.animation.core.animateFloatAsState(if (expanded) 180f else 0f, expandSpring, label = "chevron")
|
||||||
if (expanded) {
|
Row(Modifier.height(48.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
LabelField(a.label) { actions.save(a.copy(label = it)) }
|
androidx.compose.animation.Crossfade(expanded, Modifier.weight(1f), animationSpec = tween(180), label = "label") { open ->
|
||||||
} else {
|
Row(Modifier.fillMaxWidth().height(48.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(a.label, style = WType.label, color = scheme.onSurfaceVariant, modifier = Modifier.weight(1f))
|
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) {
|
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))
|
Spacer(Modifier.height(WSpace.s8))
|
||||||
@@ -208,7 +221,11 @@ private fun AlarmCard(
|
|||||||
Text(status, style = WType.bodySm, color = scheme.onSurface, modifier = Modifier.weight(1f))
|
Text(status, style = WType.bodySm, color = scheme.onSurface, modifier = Modifier.weight(1f))
|
||||||
Switch(a.enabled, onCheckedChange = { actions.save(a.copy(enabled = it, skipUntil = null)) })
|
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 {
|
Column {
|
||||||
Spacer(Modifier.height(WSpace.s12))
|
Spacer(Modifier.height(WSpace.s12))
|
||||||
DayChips(a, settings.weekStart) { actions.save(it) }
|
DayChips(a, settings.weekStart) { actions.save(it) }
|
||||||
@@ -247,17 +264,23 @@ private fun AlarmCard(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun androidx.compose.foundation.layout.RowScope.LabelField(value: String, onDone: (String) -> Unit) {
|
private fun androidx.compose.foundation.layout.RowScope.LabelField(value: String, onDone: (String) -> Unit) {
|
||||||
var text by remember(value) { mutableStateOf(value) }
|
var text by remember(value) { mutableStateOf(value) }
|
||||||
TextField(
|
androidx.compose.foundation.text.BasicTextField(
|
||||||
text, { text = it }, Modifier.weight(1f),
|
text, { text = it }, Modifier.weight(1f),
|
||||||
placeholder = { Text(stringResource(R.string.add_label)) },
|
textStyle = WType.body.copy(color = MaterialTheme.colorScheme.onSurface),
|
||||||
leadingIcon = { Icon(Icons.Outlined.Label, null) },
|
cursorBrush = androidx.compose.ui.graphics.SolidColor(MaterialTheme.colorScheme.primary),
|
||||||
singleLine = true, textStyle = WType.body,
|
singleLine = true,
|
||||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||||
keyboardActions = androidx.compose.foundation.text.KeyboardActions(onDone = { onDone(text.trim()) }),
|
keyboardActions = androidx.compose.foundation.text.KeyboardActions(onDone = { onDone(text.trim()) }),
|
||||||
colors = TextFieldDefaults.colors(
|
decorationBox = { inner ->
|
||||||
focusedContainerColor = androidx.compose.ui.graphics.Color.Transparent, unfocusedContainerColor = androidx.compose.ui.graphics.Color.Transparent,
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
unfocusedIndicatorColor = androidx.compose.ui.graphics.Color.Transparent,
|
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()) } }
|
androidx.compose.runtime.DisposableEffect(Unit) { onDispose { if (text.trim() != value) onDone(text.trim()) } }
|
||||||
}
|
}
|
||||||
@@ -366,12 +389,14 @@ fun SoundDialog(current: String?, onDismiss: () -> Unit, onPick: (String?) -> Un
|
|||||||
}
|
}
|
||||||
list
|
list
|
||||||
}
|
}
|
||||||
var preview by remember { mutableStateOf<android.media.Ringtone?>(null) }
|
// demo on the alarm stream, like the real ring: audible in silent mode and Do Not Disturb
|
||||||
androidx.compose.runtime.DisposableEffect(Unit) { onDispose { preview?.stop() } }
|
val preview = remember { SoundPreview(context) }
|
||||||
|
var playing by remember { mutableStateOf<String?>("none") }
|
||||||
|
androidx.compose.runtime.DisposableEffect(Unit) { onDispose { preview.stop() } }
|
||||||
fun play(uri: String?) {
|
fun play(uri: String?) {
|
||||||
preview?.stop()
|
if (playing == uri && preview.isPlaying) { preview.stop(); playing = "none"; return }
|
||||||
val u = when (uri) { null -> Uri.parse("android.resource://${context.packageName}/${R.raw.alarm_default}"); Alarm.SILENT -> null; else -> Uri.parse(uri) }
|
playing = uri
|
||||||
preview = u?.let { RingtoneManager.getRingtone(context, it) }?.also { it.play() }
|
preview.play(uri)
|
||||||
}
|
}
|
||||||
val file = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
val file = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||||
if (uri != null) {
|
if (uri != null) {
|
||||||
@@ -394,12 +419,49 @@ fun SoundDialog(current: String?, onDismiss: () -> Unit, onPick: (String?) -> Un
|
|||||||
) {
|
) {
|
||||||
RadioButton(selected == uri, onClick = null)
|
RadioButton(selected == uri, onClick = null)
|
||||||
Spacer(Modifier.width(WSpace.s12))
|
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)) } },
|
confirmButton = { TextButton(onClick = { preview.stop(); onPick(selected) }) { Text(stringResource(android.R.string.ok)) } },
|
||||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } },
|
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.Bedtime
|
||||||
import androidx.compose.material.icons.outlined.NotificationsActive
|
import androidx.compose.material.icons.outlined.NotificationsActive
|
||||||
import androidx.compose.material.icons.outlined.WbSunny
|
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.Button
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
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 -> 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)
|
{ 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))
|
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)) }
|
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))
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
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.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -69,6 +73,7 @@ fun ClockScreen(
|
|||||||
contentPadding: PaddingValues,
|
contentPadding: PaddingValues,
|
||||||
onDeleteCity: (City) -> Unit,
|
onDeleteCity: (City) -> Unit,
|
||||||
nowOverride: Long? = null,
|
nowOverride: Long? = null,
|
||||||
|
onReorder: (List<Long>) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val tick = remember { mutableLongStateOf(nowOverride ?: System.currentTimeMillis()) }
|
val tick = remember { mutableLongStateOf(nowOverride ?: System.currentTimeMillis()) }
|
||||||
if (nowOverride == null) LaunchedEffect(settings.showSeconds) {
|
if (nowOverride == null) LaunchedEffect(settings.showSeconds) {
|
||||||
@@ -77,6 +82,11 @@ fun ClockScreen(
|
|||||||
val now = tick.longValue
|
val now = tick.longValue
|
||||||
val res = LocalContext.current.resources
|
val res = LocalContext.current.resources
|
||||||
val scheme = MaterialTheme.colorScheme
|
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(
|
LazyColumn(
|
||||||
Modifier.fillMaxSize(),
|
Modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(start = WSpace.s16, end = WSpace.s16, top = contentPadding.calculateTopPadding(), bottom = contentPadding.calculateBottomPadding() + 120.dp),
|
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.Icons
|
||||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||||
import androidx.compose.material.icons.outlined.AccessTime
|
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.CalendarMonth
|
||||||
import androidx.compose.material.icons.outlined.DarkMode
|
import androidx.compose.material.icons.outlined.DarkMode
|
||||||
import androidx.compose.material.icons.outlined.Snooze
|
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)
|
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()))
|
Spacer(Modifier.height(32.dp()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.mutableLongStateOf
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -72,6 +73,7 @@ interface TimerActions {
|
|||||||
fun reset(id: Long)
|
fun reset(id: Long)
|
||||||
fun delete(id: Long)
|
fun delete(id: Long)
|
||||||
fun stopRinging()
|
fun stopRinging()
|
||||||
|
fun rename(id: Long, label: String) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** S08: keypad when there is no timer (or after "+"), cards for running ones. */
|
/** 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)) }
|
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)) {
|
Column(Modifier.fillMaxWidth().clip(RoundedCornerShape(WRadius.card)).background(scheme.surfaceContainerLow).padding(WSpace.s20)) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
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)) }
|
IconButton(onClick = { actions.delete(t.id) }) { Icon(Icons.Rounded.Close, stringResource(R.string.delete)) }
|
||||||
}
|
}
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
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)) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -65,6 +65,10 @@ object WColor {
|
|||||||
val timerProgress = Color(0xFF7EC4A8)
|
val timerProgress = Color(0xFF7EC4A8)
|
||||||
val nightText = Color(0xFFC4433A)
|
val nightText = Color(0xFFC4433A)
|
||||||
val nightBg = Color(0xFF000000)
|
val nightBg = Color(0xFF000000)
|
||||||
|
val sunriseNight = Color(0xFF0D0710)
|
||||||
|
val sunriseEmber = Color(0xFF5A1A10)
|
||||||
|
val sunriseDay = Color(0xFFFFD9A0)
|
||||||
|
val sunriseSun = Color(0xFFFFF3D6)
|
||||||
}
|
}
|
||||||
|
|
||||||
object WType {
|
object WType {
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
<string name="alarm_sound">Weckton</string>
|
<string name="alarm_sound">Weckton</string>
|
||||||
<string name="sound_default">Standard (Morgenklang)</string>
|
<string name="sound_default">Standard (Morgenklang)</string>
|
||||||
<string name="sound_silent">Ohne Ton</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="add_own_sound">+ Eigene Audiodatei wählen</string>
|
||||||
<string name="pick_time">Uhrzeit wählen</string>
|
<string name="pick_time">Uhrzeit wählen</string>
|
||||||
<string name="use_dial">Zifferblatt</string>
|
<string name="use_dial">Zifferblatt</string>
|
||||||
@@ -86,6 +87,19 @@
|
|||||||
<string name="at_bedtime">Zur Schlafenszeit</string>
|
<string name="at_bedtime">Zur Schlafenszeit</string>
|
||||||
<string name="minutes_before">%1$d Min. vorher</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="silence_after">Automatisch stumm nach</string>
|
||||||
<string name="snooze_length">Schlummerdauer</string>
|
<string name="snooze_length">Schlummerdauer</string>
|
||||||
<string name="alarm_volume">Lautstärke</string>
|
<string name="alarm_volume">Lautstärke</string>
|
||||||
@@ -103,6 +117,9 @@
|
|||||||
<string name="theme_system">System</string>
|
<string name="theme_system">System</string>
|
||||||
<string name="theme_light">Hell</string>
|
<string name="theme_light">Hell</string>
|
||||||
<string name="theme_dark">Dunkel</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_minutes">%1$d Minuten</string>
|
||||||
<string name="n_seconds">%1$d Sekunden</string>
|
<string name="n_seconds">%1$d Sekunden</string>
|
||||||
|
|
||||||
|
|||||||