build(uhr): alarms, ringing, timer, stopwatch, world clock, bedtime, settings, widgets

- exact alarms via setAlarmClock, rescheduled on boot/time/zone change and app update
- foreground ringing service: alarm-stream audio with gradual volume, vibration,
  full-screen screen with slide to snooze/stop, silence-after, missed notification
- timers and stopwatch keep timestamps only; live countdown/chronometer notifications
- world clock offline from the time zone database (no network permission at all)
- own alarm tone, own analog face and launcher icon
- next-ring logic unit-tested incl. DST gaps and zone changes; screenshot tests

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-17 15:16:50 +02:00
co-authored by Claude Opus 5.5
parent 887299a3a7
commit bc4cd8daf1
75 changed files with 4545 additions and 57 deletions
+97
View File
@@ -0,0 +1,97 @@
import java.util.Properties
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
id("com.google.devtools.ksp")
id("io.github.takahirom.roborazzi")
}
android {
namespace = "app.uhr"
compileSdk = 36
defaultConfig {
applicationId = "app.uhr"
minSdk = 29
targetSdk = 36
versionCode = 1
versionName = "0.1.0"
}
// Release key lives outside the repo; without it, release builds fall back to the debug key (local only).
val keyProps = Properties().apply {
val f = file(System.getProperty("user.home") + "/.android/nogoogleapps-release.properties")
if (f.exists()) f.inputStream().use { load(it) }
}
signingConfigs {
if (keyProps.isNotEmpty()) create("release") {
storeFile = file(keyProps.getProperty("storeFile"))
storePassword = keyProps.getProperty("storePassword")
keyAlias = keyProps.getProperty("keyAlias")
keyPassword = keyProps.getProperty("keyPassword")
}
}
buildTypes {
debug {
// installs next to the release app from Obtainium
applicationIdSuffix = ".debug"
resValue("string", "app_name", "Uhr (Debug)")
}
release {
resValue("string", "app_name", "Uhr")
isMinifyEnabled = true
isShrinkResources = true
signingConfig = signingConfigs.findByName("release") ?: signingConfigs.getByName("debug")
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures { compose = true }
testOptions { unitTests { isIncludeAndroidResources = true } }
}
kotlin { compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) } }
ksp { arg("room.schemaLocation", "$projectDir/schemas") }
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2025.10.01")
implementation(composeBom)
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
debugImplementation("androidx.compose.ui:ui-tooling")
implementation("androidx.activity:activity-compose:1.11.0")
implementation("androidx.core:core-ktx:1.17.0")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.9.4")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.4")
implementation("androidx.graphics:graphics-shapes:1.0.1")
implementation("androidx.room:room-runtime:2.7.0")
implementation("androidx.room:room-ktx:2.7.0")
ksp("androidx.room:room-compiler:2.7.0")
implementation("androidx.datastore:datastore-preferences:1.1.7")
implementation("androidx.work:work-runtime-ktx:2.11.0")
implementation("androidx.glance:glance-appwidget:1.2.0")
implementation("androidx.glance:glance-material3:1.2.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
testImplementation("junit:junit:4.13.2")
testImplementation("org.robolectric:robolectric:4.15.1")
testImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-test-manifest")
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.46.1")
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.46.1")
}
roborazzi { outputDir.set(rootProject.file("../replica/uhr/clone-screens")) }
View File
+289
View File
@@ -0,0 +1,289 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "577de12663ea3a99bc9d48c8ed391a91",
"entities": [
{
"tableName": "alarms",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `hour` INTEGER NOT NULL, `minute` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `days` INTEGER NOT NULL, `label` TEXT NOT NULL, `ringtone_uri` TEXT, `vibrate` INTEGER NOT NULL, `skip_until` TEXT, `is_bedtime_wake` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "hour",
"columnName": "hour",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "minute",
"columnName": "minute",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "enabled",
"columnName": "enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "days",
"columnName": "days",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "label",
"columnName": "label",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "ringtoneUri",
"columnName": "ringtone_uri",
"affinity": "TEXT"
},
{
"fieldPath": "vibrate",
"columnName": "vibrate",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "skipUntil",
"columnName": "skip_until",
"affinity": "TEXT"
},
{
"fieldPath": "isBedtimeWake",
"columnName": "is_bedtime_wake",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updated_at",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_alarms_hour_minute",
"unique": false,
"columnNames": [
"hour",
"minute"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_alarms_hour_minute` ON `${TABLE_NAME}` (`hour`, `minute`)"
}
]
},
{
"tableName": "alarm_instances",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`alarm_id` INTEGER NOT NULL, `fire_at` INTEGER NOT NULL, `state` TEXT NOT NULL, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`alarm_id`), FOREIGN KEY(`alarm_id`) REFERENCES `alarms`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "alarmId",
"columnName": "alarm_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "fireAt",
"columnName": "fire_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "state",
"columnName": "state",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updated_at",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"alarm_id"
]
},
"indices": [
{
"name": "index_alarm_instances_state_fire_at",
"unique": false,
"columnNames": [
"state",
"fire_at"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_alarm_instances_state_fire_at` ON `${TABLE_NAME}` (`state`, `fire_at`)"
}
],
"foreignKeys": [
{
"table": "alarms",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"alarm_id"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "timers",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `label` TEXT NOT NULL, `duration_ms` INTEGER NOT NULL, `remaining_ms` INTEGER NOT NULL, `end_at` INTEGER, `state` TEXT NOT NULL, `created_at` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "label",
"columnName": "label",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "durationMs",
"columnName": "duration_ms",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "remainingMs",
"columnName": "remaining_ms",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "endAt",
"columnName": "end_at",
"affinity": "INTEGER"
},
{
"fieldPath": "state",
"columnName": "state",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_timers_state_end_at",
"unique": false,
"columnNames": [
"state",
"end_at"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_timers_state_end_at` ON `${TABLE_NAME}` (`state`, `end_at`)"
}
]
},
{
"tableName": "cities",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `zone_id` TEXT NOT NULL, `name` TEXT NOT NULL, `sort_order` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "zoneId",
"columnName": "zone_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sortOrder",
"columnName": "sort_order",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_cities_zone_id",
"unique": true,
"columnNames": [
"zone_id"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cities_zone_id` ON `${TABLE_NAME}` (`zone_id`)"
},
{
"name": "index_cities_sort_order",
"unique": false,
"columnNames": [
"sort_order"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_cities_sort_order` ON `${TABLE_NAME}` (`sort_order`)"
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '577de12663ea3a99bc9d48c8ed391a91')"
]
}
}
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity android:name=".DebugAlarmActivity" android:exported="true" android:theme="@android:style/Theme.Translucent.NoTitleBar" />
</application>
</manifest>
@@ -0,0 +1,27 @@
package app.uhr
import android.app.Activity
import android.os.Bundle
import android.util.Log
import app.uhr.data.Alarm
import app.uhr.data.Graph
import kotlinx.coroutines.runBlocking
import java.time.LocalTime
/** Debug only: `--ei in_seconds 70` saves a real alarm that far ahead, through the normal repository path. */
class DebugAlarmActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Graph.init(this)
val timerS = intent.getIntExtra("timer_seconds", 0)
if (timerS > 0) {
val id = runBlocking { Graph.repo.startTimer(timerS * 1000L, "Testtimer") }
Log.i("DebugAlarm", "timer $id for ${timerS}s"); finish(); return
}
val t = LocalTime.now().plusSeconds(intent.getIntExtra("in_seconds", 70).toLong())
val id = runBlocking { Graph.repo.save(Alarm(hour = t.hour, minute = t.minute, label = intent.getStringExtra("label") ?: "Test")) }
val inst = runBlocking { Graph.repo.instance(id) }
Log.i("DebugAlarm", "alarm $id at ${t.hour}:${t.minute} fires ${inst?.fireAt}")
finish()
}
}
+64
View File
@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- no INTERNET, no location: nothing leaves the device -->
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application
android:name=".UhrApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@android:style/Theme.Material.NoActionBar">
<activity android:name=".MainActivity" android:exported="true" android:launchMode="singleTop" android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".alarm.RingingActivity"
android:exported="false"
android:excludeFromRecents="true"
android:launchMode="singleInstance"
android:showWhenLocked="true"
android:turnScreenOn="true"
android:taskAffinity="" />
<service android:name=".alarm.RingingService" android:exported="false" android:foregroundServiceType="mediaPlayback" />
<receiver android:name=".alarm.AlarmReceiver" android:exported="false" android:directBootAware="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.TIME_SET" />
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<receiver android:name=".widget.DigitalClockReceiver" android:exported="true" android:label="@string/widget_digital_name">
<intent-filter><action android:name="android.appwidget.action.APPWIDGET_UPDATE" /></intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widget_digital" />
</receiver>
<receiver android:name=".widget.AnalogClockReceiver" android:exported="true" android:label="@string/widget_analog_name">
<intent-filter><action android:name="android.appwidget.action.APPWIDGET_UPDATE" /></intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widget_analog" />
</receiver>
<receiver android:name=".widget.NextAlarmReceiver" android:exported="true" android:label="@string/widget_next_name">
<intent-filter><action android:name="android.appwidget.action.APPWIDGET_UPDATE" /></intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widget_next" />
</receiver>
</application>
</manifest>
@@ -0,0 +1,260 @@
package app.uhr
import android.Manifest
import android.app.NotificationManager
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.provider.Settings as SystemSettings
import android.text.format.DateFormat
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Alarm
import androidx.compose.material.icons.outlined.Bedtime
import androidx.compose.material.icons.outlined.HourglassEmpty
import androidx.compose.material.icons.outlined.Schedule
import androidx.compose.material.icons.outlined.Timer
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.MoreVert
import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import app.uhr.alarm.RingingService
import app.uhr.data.Alarm
import app.uhr.data.Graph
import app.uhr.data.Settings
import app.uhr.ui.components.Fmt
import app.uhr.ui.screens.AddCityDialog
import app.uhr.ui.screens.AlarmActions
import app.uhr.ui.screens.AlarmScreen
import app.uhr.ui.screens.BedtimeScreen
import app.uhr.ui.screens.ClockScreen
import app.uhr.ui.screens.SettingsScreen
import app.uhr.ui.screens.StopwatchScreen
import app.uhr.ui.screens.TimeDialog
import app.uhr.ui.screens.TimerActions
import app.uhr.ui.screens.TimerScreen
import app.uhr.ui.screens.digitsToMs
import app.uhr.ui.theme.ClockTheme
import kotlinx.coroutines.launch
import java.time.LocalTime
class MainActivity : ComponentActivity() {
private var tabFromIntent = mutableIntStateOf(-1)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
Graph.init(this)
Fmt.is24h = DateFormat.is24HourFormat(this)
tabFromIntent.intValue = intent.getIntExtra(EXTRA_TAB, -1)
setContent {
val settings by Graph.repo.prefs.settings.collectAsState(initial = Settings())
ClockTheme(settings.theme) { ClockApp(settings, tabFromIntent.intValue) }
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
tabFromIntent.intValue = intent.getIntExtra(EXTRA_TAB, -1)
}
override fun onResume() {
super.onResume()
Fmt.is24h = DateFormat.is24HourFormat(this)
}
companion object { const val EXTRA_TAB = "tab" }
}
private data class Tab(val title: Int, val icon: ImageVector)
private val TABS = listOf(
Tab(R.string.tab_alarm, Icons.Outlined.Alarm),
Tab(R.string.tab_clock, Icons.Outlined.Schedule),
Tab(R.string.tab_timer, Icons.Outlined.HourglassEmpty),
Tab(R.string.tab_stopwatch, Icons.Outlined.Timer),
Tab(R.string.tab_bedtime, Icons.Outlined.Bedtime),
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ClockApp(settings: Settings, requestedTab: Int) {
val repo = Graph.repo
val scope = rememberCoroutineScope()
val context = LocalContext.current
var tab by rememberSaveable { mutableIntStateOf(0) }
LaunchedEffect(requestedTab) { if (requestedTab in TABS.indices) tab = requestedTab }
var showSettings by rememberSaveable { mutableStateOf(false) }
var menu by remember { mutableStateOf(false) }
var addAlarm by remember { mutableStateOf(false) }
var addCity by remember { mutableStateOf(false) }
var keypad by rememberSaveable { mutableStateOf(false) }
var digits by rememberSaveable { mutableStateOf("") }
val snackbar = remember { SnackbarHostState() }
val alarms by repo.alarms.collectAsState(initial = emptyList())
val timers by repo.timers.collectAsState(initial = emptyList())
val cities by repo.cities.collectAsState(initial = emptyList())
val next by repo.nextInstance.collectAsState(initial = null)
val sw by repo.stopwatch.collectAsState(initial = app.uhr.data.Stopwatch())
val bedtime by repo.bedtime.collectAsState(initial = app.uhr.data.Bedtime())
PermissionGate()
if (showSettings) {
BackHandler { showSettings = false }
SettingsScreen(settings, { t -> scope.launch { repo.prefs.update(t) } }, onBack = { showSettings = false })
return
}
val alarmActions = remember {
object : AlarmActions {
override fun save(alarm: Alarm) { scope.launch { repo.save(alarm) } }
override fun delete(alarm: Alarm) { scope.launch { repo.delete(alarm) } }
override fun restore(alarm: Alarm) { scope.launch { repo.restore(alarm) } }
override fun dismissNext(alarm: Alarm) { scope.launch { repo.dismissNext(alarm.id) } }
}
}
val timerActions = remember {
object : TimerActions {
override fun start(durationMs: Long) { scope.launch { repo.startTimer(durationMs) } }
override fun pause(id: Long) { scope.launch { repo.pauseTimer(id) } }
override fun resume(id: Long) { scope.launch { repo.resumeTimer(id) } }
override fun addMinute(id: Long) { scope.launch { repo.addMinute(id) } }
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) }
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(TABS[tab].title)) },
actions = {
IconButton(onClick = { menu = true }) { Icon(Icons.Rounded.MoreVert, stringResource(R.string.more)) }
DropdownMenu(menu, { menu = false }) {
DropdownMenuItem(text = { Text(stringResource(R.string.settings)) }, onClick = { menu = false; showSettings = true })
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surface),
)
},
bottomBar = {
NavigationBar {
TABS.forEachIndexed { i, t ->
NavigationBarItem(selected = tab == i, onClick = { tab = i }, icon = { Icon(t.icon, null) }, label = { Text(stringResource(t.title)) })
}
}
},
floatingActionButton = {
val fab: (() -> Unit)? = when (tab) {
0 -> ({ addAlarm = true })
1 -> ({ addCity = true })
2 -> if (timers.isEmpty() || keypad) {
if (digits.isNotEmpty()) ({ timerActions.start(digitsToMs(digits)); digits = ""; keypad = false }) else null
} else ({ keypad = true })
else -> null
}
if (fab != null) {
val icon = if (tab == 2 && (timers.isEmpty() || keypad)) Icons.Rounded.PlayArrow else Icons.Rounded.Add
FloatingActionButton(onClick = fab, shape = CircleShape, modifier = Modifier.size(96.dp),
containerColor = app.uhr.ui.theme.fabColors().first, contentColor = app.uhr.ui.theme.fabColors().second) {
Icon(icon, stringResource(R.string.add), Modifier.size(36.dp))
}
}
},
floatingActionButtonPosition = androidx.compose.material3.FabPosition.Center,
snackbarHost = { SnackbarHost(snackbar) },
) { pad ->
when (tab) {
0 -> AlarmScreen(alarms, settings, alarmActions, snackbar, pad)
1 -> ClockScreen(settings, cities, next, pad, onDeleteCity = { c -> scope.launch { repo.deleteCity(c.id) } })
2 -> {
if (keypad && timers.isNotEmpty()) BackHandler { keypad = false; digits = "" }
TimerScreen(timers, keypad, digits, { digits = it }, timerActions, pad)
}
3 -> StopwatchScreen(sw, { scope.launch { repo.stopwatchToggle() } }, { scope.launch { repo.stopwatchLap() } }, { scope.launch { repo.stopwatchReset() } }, pad)
4 -> BedtimeScreen(bedtime, settings, { b -> scope.launch { repo.saveBedtime(b) } }, pad)
}
}
if (addAlarm) {
val t = LocalTime.now().plusHours(1).withMinute(0)
TimeDialog(t.hour, t.minute, { addAlarm = false }) { h, m ->
addAlarm = false
scope.launch {
val id = repo.save(Alarm(hour = h, minute = m))
repo.instance(id)?.let { snackbar.showSnackbar(context.getString(R.string.alarm_set_in, Fmt.until(context.resources, it.fireAt))) }
}
}
}
if (addCity) AddCityDialog({ addCity = false }) { hit -> addCity = false; scope.launch { repo.addCity(hit) } }
}
/** Notifications and full-screen ringing are what make an alarm app work: ask once, explain if missing. */
@Composable
private fun PermissionGate() {
val context = LocalContext.current
var asked by rememberSaveable { mutableStateOf(false) }
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { }
LaunchedEffect(Unit) {
if (!asked && Build.VERSION.SDK_INT >= 33) { asked = true; launcher.launch(Manifest.permission.POST_NOTIFICATIONS) }
}
val nm = context.getSystemService(NotificationManager::class.java)
var hide by rememberSaveable { mutableStateOf(false) }
if (Build.VERSION.SDK_INT >= 34 && !nm.canUseFullScreenIntent() && !hide) {
androidx.compose.material3.AlertDialog(
onDismissRequest = { hide = true },
title = { Text(stringResource(R.string.fsi_title)) },
text = { Text(stringResource(R.string.fsi_text)) },
confirmButton = {
TextButton(onClick = {
hide = true
context.startActivity(Intent(SystemSettings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT, android.net.Uri.parse("package:${context.packageName}")))
}) { Text(stringResource(R.string.open_settings)) }
},
dismissButton = { TextButton(onClick = { hide = true }) { Text(stringResource(R.string.later)) } },
)
}
}
+13
View File
@@ -0,0 +1,13 @@
package app.uhr
import android.app.Application
import app.uhr.alarm.Notifications
import app.uhr.data.Graph
class UhrApp : Application() {
override fun onCreate() {
super.onCreate()
Graph.init(this)
Notifications.createChannels(this)
}
}
@@ -0,0 +1,56 @@
package app.uhr.alarm
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import app.uhr.data.Graph
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
/** Entry point for every scheduled event and every notification action. */
class AlarmReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
Graph.init(context)
val id = intent.getLongExtra(EXTRA_ID, -1)
when (intent.action) {
// ringing must start while the receiver is alive: foreground service right away
ACTION_RING -> RingingService.startAlarm(context, id)
ACTION_TIMER -> RingingService.startTimer(context, id)
else -> {
val pending = goAsync()
scope.launch {
try { handle(context, intent.action, id) } finally { pending.finish() }
}
}
}
}
private suspend fun handle(context: Context, action: String?, id: Long) {
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()
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_BEDTIME -> { Notifications.bedtime(context); repo.scheduleBedtime() }
}
}
companion object {
const val EXTRA_ID = "id"
const val ACTION_RING = "app.uhr.RING"
const val ACTION_UPCOMING = "app.uhr.UPCOMING"
const val ACTION_DISMISS_NEXT = "app.uhr.DISMISS_NEXT"
const val ACTION_SNOOZE_DISMISS = "app.uhr.SNOOZE_DISMISS"
const val ACTION_TIMER = "app.uhr.TIMER"
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"
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
}
}
@@ -0,0 +1,158 @@
package app.uhr.alarm
import android.Manifest
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.SystemClock
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import app.uhr.MainActivity
import app.uhr.R
import app.uhr.data.Alarm
import app.uhr.data.Stopwatch
import app.uhr.data.StopwatchState
import app.uhr.data.Timer
import app.uhr.data.TimerState
import java.text.DateFormat
import java.util.Date
object Notifications {
const val CH_RINGING = "ringing"
const val CH_UPCOMING = "upcoming"
const val CH_MISSED = "missed"
const val CH_RUNNING = "running"
const val CH_BEDTIME = "bedtime"
const val ID_TIMERS = 1
const val ID_STOPWATCH = 2
const val ID_RINGING = 3
const val ID_BEDTIME = 4
private const val ALARM_BASE = 1000
fun createChannels(context: Context) {
val nm = context.getSystemService(NotificationManager::class.java)
nm.createNotificationChannels(
listOf(
NotificationChannel(CH_RINGING, context.getString(R.string.ch_ringing), NotificationManager.IMPORTANCE_HIGH).apply {
setSound(null, null); enableVibration(false); setBypassDnd(true)
},
NotificationChannel(CH_UPCOMING, context.getString(R.string.ch_upcoming), NotificationManager.IMPORTANCE_LOW),
NotificationChannel(CH_MISSED, context.getString(R.string.ch_missed), NotificationManager.IMPORTANCE_DEFAULT),
NotificationChannel(CH_RUNNING, context.getString(R.string.ch_running), NotificationManager.IMPORTANCE_LOW),
NotificationChannel(CH_BEDTIME, context.getString(R.string.ch_bedtime), NotificationManager.IMPORTANCE_DEFAULT),
)
)
}
private fun allowed(context: Context) =
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
private fun nm(context: Context) = context.getSystemService(NotificationManager::class.java)
private fun open(context: Context, tab: Int = 0) = PendingIntent.getActivity(
context, 100 + tab, Intent(context, MainActivity::class.java).putExtra(MainActivity.EXTRA_TAB, tab),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
private fun action(context: Context, action: String, id: Long) = PendingIntent.getBroadcast(
context, (action.hashCode() * 31 + id).toInt(),
Intent(context, AlarmReceiver::class.java).setAction(action).putExtra(AlarmReceiver.EXTRA_ID, id),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
private fun time(at: Long) = DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(at))
private fun title(context: Context, a: Alarm) = a.label.ifBlank { context.getString(R.string.alarm) }
fun upcoming(context: Context, a: Alarm, fireAt: Long) {
if (!allowed(context)) return
nm(context).notify(
(ALARM_BASE + a.id).toInt(),
NotificationCompat.Builder(context, CH_UPCOMING).setSmallIcon(R.drawable.ic_stat_alarm)
.setContentTitle(context.getString(R.string.notif_upcoming, time(fireAt)))
.setContentText(title(context, a)).setContentIntent(open(context))
.addAction(0, context.getString(R.string.dismiss_now), action(context, AlarmReceiver.ACTION_DISMISS_NEXT, a.id))
.setCategory(NotificationCompat.CATEGORY_REMINDER).build(),
)
}
fun snoozed(context: Context, a: Alarm, until: Long) {
if (!allowed(context)) return
nm(context).notify(
(ALARM_BASE + a.id).toInt(),
NotificationCompat.Builder(context, CH_UPCOMING).setSmallIcon(R.drawable.ic_stat_alarm)
.setContentTitle(context.getString(R.string.notif_snoozed, time(until)))
.setContentText(title(context, a)).setContentIntent(open(context)).setOngoing(true)
.addAction(0, context.getString(R.string.stop), action(context, AlarmReceiver.ACTION_SNOOZE_DISMISS, a.id))
.build(),
)
}
fun missed(context: Context, a: Alarm) {
if (!allowed(context)) return
nm(context).notify(
(ALARM_BASE + a.id).toInt(),
NotificationCompat.Builder(context, CH_MISSED).setSmallIcon(R.drawable.ic_stat_alarm)
.setContentTitle(context.getString(R.string.notif_missed))
.setContentText("${title(context, a)} · ${String.format("%d:%02d", a.hour, a.minute)}")
.setContentIntent(open(context)).setAutoCancel(true).build(),
)
}
fun cancelAlarm(context: Context, id: Long) = nm(context).cancel((ALARM_BASE + id).toInt())
/** One notification for all running/paused timers; the soonest one counts down live. */
fun timers(context: Context, timers: List<Timer>) {
val active = timers.filter { it.state != TimerState.RINGING }
if (active.isEmpty() || !allowed(context)) { nm(context).cancel(ID_TIMERS); return }
val next = active.filter { it.state == TimerState.RUNNING }.minByOrNull { it.endAt ?: Long.MAX_VALUE } ?: active.first()
val b = NotificationCompat.Builder(context, CH_RUNNING).setSmallIcon(R.drawable.ic_stat_timer)
.setContentTitle(next.label.ifBlank { context.getString(R.string.timer) })
.setContentIntent(open(context, 2)).setOngoing(true).setOnlyAlertOnce(true).setSilent(true)
.setCategory(NotificationCompat.CATEGORY_STOPWATCH)
if (next.state == TimerState.RUNNING) {
b.setUsesChronometer(true).setChronometerCountDown(true).setWhen(next.endAt ?: 0).setShowWhen(true)
b.addAction(0, context.getString(R.string.pause), action(context, AlarmReceiver.ACTION_TIMER_PAUSE, next.id))
} else {
b.setContentText(context.getString(R.string.paused))
}
b.addAction(0, context.getString(R.string.plus_minute), action(context, AlarmReceiver.ACTION_TIMER_ADD_MINUTE, next.id))
if (active.size > 1) b.setSubText(context.getString(R.string.n_timers, active.size))
nm(context).notify(ID_TIMERS, b.build())
}
fun stopwatch(context: Context, s: Stopwatch) {
if (s.state != StopwatchState.RUNNING || !allowed(context)) { nm(context).cancel(ID_STOPWATCH); return }
// chronometer counts from a wall-clock base: translate the elapsedRealtime start
val base = System.currentTimeMillis() - (SystemClock.elapsedRealtime() - s.startedAt) - s.accumulatedMs
nm(context).notify(
ID_STOPWATCH,
NotificationCompat.Builder(context, CH_RUNNING).setSmallIcon(R.drawable.ic_stat_timer)
.setContentTitle(context.getString(R.string.stopwatch)).setUsesChronometer(true).setWhen(base).setShowWhen(true)
.setContentIntent(open(context, 3)).setOngoing(true).setSilent(true).build(),
)
}
fun bedtime(context: Context) {
if (!allowed(context)) return
nm(context).notify(
ID_BEDTIME,
NotificationCompat.Builder(context, CH_BEDTIME).setSmallIcon(R.drawable.ic_stat_bedtime)
.setContentTitle(context.getString(R.string.notif_bedtime)).setContentText(context.getString(R.string.notif_bedtime_text))
.setContentIntent(open(context, 4)).setAutoCancel(true).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)
.setCategory(NotificationCompat.CATEGORY_ALARM).setPriority(NotificationCompat.PRIORITY_MAX)
.setFullScreenIntent(fullScreen, true).setContentIntent(fullScreen).setOngoing(true)
.apply { if (snooze != null) addAction(0, context.getString(R.string.snooze), snooze) }
.addAction(0, context.getString(R.string.stop), stop)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC).build()
}
@@ -0,0 +1,169 @@
package app.uhr.alarm
import android.os.Bundle
import android.view.KeyEvent
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Alarm
import androidx.compose.material.icons.rounded.HourglassBottom
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.CustomAccessibilityAction
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import app.uhr.R
import app.uhr.data.Graph
import app.uhr.data.VolumeButtons
import app.uhr.ui.components.Fmt
import app.uhr.ui.theme.WType
import app.uhr.ui.theme.WColor
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.math.abs
import kotlin.math.roundToInt
/** Full-screen ringing UI, shown over the lock screen. */
class RingingActivity : ComponentActivity() {
private var volumeButtons = VolumeButtons.VOLUME
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setShowWhenLocked(true)
setTurnScreenOn(true)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
enableEdgeToEdge()
Graph.init(this)
setContent {
val ring by RingingService.ringing.collectAsState()
LaunchedEffect(Unit) { volumeButtons = Graph.repo.prefs.settingsOnce().volumeButtons }
LaunchedEffect(ring) { if (ring == null) { delay(300); if (RingingService.ringing.value == null) finish() } }
ring?.let { RingingScreen(it, onSnooze = { RingingService.snooze(this) }, onStop = { RingingService.stop(this) }) }
}
}
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
if (keyCode != KeyEvent.KEYCODE_VOLUME_UP && keyCode != KeyEvent.KEYCODE_VOLUME_DOWN) return super.onKeyDown(keyCode, event)
when (volumeButtons) {
VolumeButtons.SNOOZE -> if (RingingService.ringing.value is Ring.AlarmRing) RingingService.snooze(this) else RingingService.stop(this)
VolumeButtons.STOP -> RingingService.stop(this)
VolumeButtons.NOTHING -> Unit
VolumeButtons.VOLUME -> return super.onKeyDown(keyCode, event)
}
return true
}
}
@Composable
fun RingingScreen(ring: Ring, onSnooze: () -> Unit, onStop: () -> Unit) {
val now = remember { mutableLongStateOf(System.currentTimeMillis()) }
LaunchedEffect(Unit) { while (true) { now.longValue = System.currentTimeMillis(); delay(1000) } }
val label = when (ring) {
is Ring.AlarmRing -> ring.alarm.label.ifBlank { stringResource(R.string.alarm) }
is Ring.TimerRing -> ring.label.ifBlank { stringResource(R.string.timer_done) }
}
Column(
Modifier.fillMaxSize().background(WColor.ringBg).navigationBarsPadding().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(Modifier.weight(1f))
val big = when (ring) {
is Ring.AlarmRing -> Fmt.clock(now.longValue)
// a finished timer counts up the time since it ended
is Ring.TimerRing -> "−" + Fmt.duration(now.longValue - ring.since)
}
Text(big, style = WType.ringing, color = WColor.ringOn)
Spacer(Modifier.height(8.dp))
Text(label, style = WType.title, color = WColor.ringOn)
Spacer(Modifier.weight(1.2f))
val snooze = if (ring is Ring.AlarmRing) onSnooze else null
SlideToAct(
leftLabel = if (snooze != null) stringResource(R.string.snooze) else stringResource(R.string.stop),
rightLabel = stringResource(R.string.stop),
icon = if (ring is Ring.AlarmRing) Icons.Rounded.Alarm else Icons.Rounded.HourglassBottom,
onLeft = snooze ?: onStop, onRight = onStop,
)
Spacer(Modifier.height(32.dp))
}
}
/** Pill track with an alarm thumb in the middle: drag left to snooze, right to stop. */
@Composable
fun SlideToAct(leftLabel: String, rightLabel: String, icon: ImageVector, onLeft: () -> Unit, onRight: () -> Unit) {
val scope = rememberCoroutineScope()
val offset = remember { Animatable(0f) }
BoxWithConstraints(
Modifier.fillMaxWidth().height(76.dp).clip(RoundedCornerShape(38.dp)).background(WColor.ringTrack)
.semantics {
customActions = listOf(
CustomAccessibilityAction(leftLabel) { onLeft(); true },
CustomAccessibilityAction(rightLabel) { onRight(); true },
)
},
) {
val maxPx = with(LocalDensity.current) { (maxWidth / 2 - 38.dp).toPx() }
Row(Modifier.fillMaxSize().padding(horizontal = 24.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
Text(leftLabel, style = WType.label, color = WColor.ringOn, modifier = Modifier.clickable(onClick = onLeft))
Text(rightLabel, style = WType.label, color = WColor.ringOn, modifier = Modifier.clickable(onClick = onRight))
}
Box(
Modifier.align(Alignment.Center).offset { IntOffset(offset.value.roundToInt(), 0) }
.size(width = 96.dp, height = 64.dp).clip(RoundedCornerShape(32.dp)).background(WColor.ringThumb)
.pointerInput(maxPx) {
detectHorizontalDragGestures(
onDragEnd = {
scope.launch {
val v = offset.value
when {
v < -maxPx * .6f -> { offset.snapTo(-maxPx); onLeft() }
v > maxPx * .6f -> { offset.snapTo(maxPx); onRight() }
else -> offset.animateTo(0f, spring(dampingRatio = .6f))
}
}
},
) { _, drag -> scope.launch { offset.snapTo((offset.value + drag).coerceIn(-maxPx, maxPx)) } }
}
.semantics { contentDescription = "$leftLabel / $rightLabel" },
contentAlignment = Alignment.Center,
) { Icon(icon, null, tint = WColor.onRingThumb, modifier = Modifier.size(28.dp)) }
}
}
@@ -0,0 +1,200 @@
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.AudioManager
import android.media.MediaPlayer
import android.media.RingtoneManager
import android.net.Uri
import android.os.IBinder
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import androidx.core.app.ServiceCompat
import app.uhr.R
import app.uhr.data.Alarm
import app.uhr.data.Graph
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
/** What is ringing right now, for the full-screen activity. */
sealed interface Ring {
data class AlarmRing(val alarm: Alarm) : Ring
data class TimerRing(val timerId: Long, val label: String, val since: Long) : Ring
}
/**
* Plays the alarm or timer: sound on the alarm stream with gradual volume, vibration,
* a full-screen notification, and the "silence after" timeout.
*/
class RingingService : Service() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var player: MediaPlayer? = null
private var vibrator: Vibrator? = null
private var timeout: Job? = null
private var ramp: Job? = null
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Graph.init(this)
val id = intent?.getLongExtra(AlarmReceiver.EXTRA_ID, -1) ?: -1
// must be in the foreground within seconds, before any database work
goForeground(getString(R.string.alarm), "")
when (intent?.action) {
ACTION_ALARM -> scope.launch { startAlarm(id) }
ACTION_TIMER -> scope.launch { startTimer(id) }
ACTION_SNOOZE -> scope.launch { (current.value as? Ring.AlarmRing)?.let { Graph.repo.snooze(it.alarm.id) }; stop() }
ACTION_STOP -> scope.launch { stopCurrent(); stop() }
else -> stop()
}
return START_NOT_STICKY
}
private suspend fun startAlarm(id: Long) {
val repo = Graph.repo
val alarm = repo.alarm(id) ?: return stop()
if (!alarm.enabled) return stop()
repo.markRinging(id)
val ring = Ring.AlarmRing(alarm)
current.value = ring
val title = alarm.label.ifBlank { getString(R.string.alarm) }
goForeground(title, String.format("%d:%02d", alarm.hour, alarm.minute), snooze = true)
val s = repo.prefs.settingsOnce()
play(alarm.ringtoneUri, s.alarmVolume, s.gradualVolumeS)
if (alarm.vibrate) vibrate()
timeout?.cancel()
timeout = scope.launch {
delay(s.silenceAfterMin * 60_000L)
repo.finishAlarm(id, missed = true)
stop()
}
}
private suspend fun startTimer(id: Long) {
val repo = Graph.repo
val t = repo.timer(id) ?: return stop()
repo.markTimerRinging(id)
current.value = Ring.TimerRing(id, t.label, t.endAt ?: System.currentTimeMillis())
goForeground(t.label.ifBlank { getString(R.string.timer_done) }, getString(R.string.timer_done), snooze = false)
val s = repo.prefs.settingsOnce()
play(s.timerSound, s.alarmVolume, 0)
if (s.timerVibrate) vibrate()
timeout?.cancel()
timeout = scope.launch { delay(s.silenceAfterMin * 60_000L); stopCurrent(); stop() }
}
private suspend fun stopCurrent() {
when (val r = current.value) {
is Ring.AlarmRing -> Graph.repo.finishAlarm(r.alarm.id)
is Ring.TimerRing -> Graph.repo.deleteTimer(r.timerId)
null -> Unit
}
}
private fun goForeground(title: String, text: String, snooze: Boolean = false) {
val full = PendingIntent.getActivity(
this, 0, Intent(this, RingingActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NO_USER_ACTION),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val n = Notifications.ringing(this, title, text, full, self(ACTION_STOP), if (snooze) self(ACTION_SNOOZE) else null)
ServiceCompat.startForeground(this, Notifications.ID_RINGING, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK)
}
private fun self(action: String) = PendingIntent.getService(
this, action.hashCode(), Intent(this, RingingService::class.java).setAction(action), PendingIntent.FLAG_IMMUTABLE,
)
private fun play(uri: String?, volumePercent: Int, rampSeconds: Int) {
player?.release(); player = null
if (uri == Alarm.SILENT) return
val audio = getSystemService(AudioManager::class.java)
val target = (volumePercent / 100f).coerceIn(0.05f, 1f)
val candidates = listOfNotNull(
uri?.let(Uri::parse),
RingtoneManager.getActualDefaultRingtoneUri(this, RingtoneManager.TYPE_ALARM),
Uri.parse("android.resource://$packageName/${R.raw.alarm_default}"),
)
// a picked file may be gone: try the next one rather than ring silently
for (u in candidates) {
val p = runCatching {
MediaPlayer().apply {
setAudioAttributes(AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_ALARM).setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION).build())
setDataSource(this@RingingService, u)
isLooping = true
prepare()
}
}.getOrNull() ?: continue
player = p
break
}
val p = player ?: return
if (audio.getStreamVolume(AudioManager.STREAM_ALARM) == 0) {
audio.setStreamVolume(AudioManager.STREAM_ALARM, audio.getStreamMaxVolume(AudioManager.STREAM_ALARM) / 2, 0)
}
p.setVolume(if (rampSeconds > 0) 0.05f else target, if (rampSeconds > 0) 0.05f else target)
p.start()
ramp?.cancel()
if (rampSeconds > 0) ramp = scope.launch {
val steps = rampSeconds * 4
for (i in 1..steps) {
delay(250)
val v = 0.05f + (target - 0.05f) * i / steps
runCatching { p.setVolume(v, v) }
}
}
}
private fun vibrate() {
val v = getSystemService(VibratorManager::class.java)?.defaultVibrator ?: return
vibrator = v
v.vibrate(VibrationEffect.createWaveform(longArrayOf(0, 600, 400, 600, 1200), 0),
android.os.VibrationAttributes.createForUsage(android.os.VibrationAttributes.USAGE_ALARM))
}
private fun stop() {
timeout?.cancel(); ramp?.cancel()
player?.runCatching { stop(); release() }; player = null
vibrator?.cancel(); vibrator = null
current.value = null
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
stopSelf()
}
override fun onDestroy() {
player?.release(); vibrator?.cancel(); current.value = null
scope.cancel()
super.onDestroy()
}
companion object {
private const val ACTION_ALARM = "alarm"
private const val ACTION_TIMER = "timer"
const val ACTION_SNOOZE = "snooze"
const val ACTION_STOP = "stop"
private val _current = MutableStateFlow<Ring?>(null)
val current: MutableStateFlow<Ring?> get() = _current
val ringing: StateFlow<Ring?> get() = _current
fun startAlarm(context: Context, id: Long) = start(context, ACTION_ALARM, id)
fun startTimer(context: Context, id: Long) = start(context, ACTION_TIMER, id)
fun snooze(context: Context) = start(context, ACTION_SNOOZE, -1)
fun stop(context: Context) = start(context, ACTION_STOP, -1)
private fun start(context: Context, action: String, id: Long) = context.startForegroundService(
Intent(context, RingingService::class.java).setAction(action).putExtra(AlarmReceiver.EXTRA_ID, id),
)
}
}
@@ -0,0 +1,93 @@
package app.uhr.alarm
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import app.uhr.MainActivity
import app.uhr.data.Alarm
import app.uhr.data.AlarmInstance
import app.uhr.data.ClockDao
import app.uhr.data.InstanceState
import app.uhr.data.Timer
import app.uhr.data.nextRing
import java.time.ZonedDateTime
/** All AlarmManager access. Alarms use setAlarmClock: Doze-exempt and shown in the status bar. */
class Scheduler(private val context: Context, private val dao: ClockDao) {
private val am = context.getSystemService(AlarmManager::class.java)
suspend fun reschedule(alarm: Alarm, now: ZonedDateTime = ZonedDateTime.now()) {
cancel(alarm.id)
if (!alarm.enabled) { dao.deleteInstance(alarm.id); Notifications.cancelAlarm(context, alarm.id); return }
val fireAt = nextRing(alarm, now).toInstant().toEpochMilli()
dao.putInstance(AlarmInstance(alarm.id, fireAt, InstanceState.SCHEDULED))
setRing(alarm.id, fireAt)
scheduleUpcomingNotice(alarm.id, fireAt)
Notifications.cancelAlarm(context, alarm.id)
}
suspend fun snooze(alarmId: Long, minutes: Int) {
val fireAt = System.currentTimeMillis() + minutes * 60_000L
dao.putInstance(AlarmInstance(alarmId, fireAt, InstanceState.SNOOZED))
setRing(alarmId, fireAt)
}
/** After a reboot, a time change or an app update: every alarm from scratch, snoozes kept. */
suspend fun rescheduleAll() {
val instances = dao.instancesOnce().associateBy { it.alarmId }
val now = System.currentTimeMillis()
for (alarm in dao.alarmsOnce()) {
val snoozed = instances[alarm.id]?.takeIf { it.state == InstanceState.SNOOZED && it.fireAt > now }
if (alarm.enabled && snoozed != null) setRing(alarm.id, snoozed.fireAt) else reschedule(alarm)
}
dao.timersOnce().filter { it.endAt != null && it.state == app.uhr.data.TimerState.RUNNING }.forEach { scheduleTimer(it) }
}
fun cancel(alarmId: Long) {
am.cancel(ringIntent(alarmId))
am.cancel(upcomingIntent(alarmId))
}
private fun setRing(alarmId: Long, fireAt: Long) {
val show = PendingIntent.getActivity(
context, alarmId.toInt(), Intent(context, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
am.setAlarmClock(AlarmManager.AlarmClockInfo(fireAt, show), ringIntent(alarmId))
}
private fun scheduleUpcomingNotice(alarmId: Long, fireAt: Long) {
val at = fireAt - UPCOMING_MS
if (at > System.currentTimeMillis()) am.set(AlarmManager.RTC_WAKEUP, at, upcomingIntent(alarmId))
}
fun scheduleTimer(timer: Timer) {
val end = timer.endAt ?: return
am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, end, timerIntent(timer.id))
}
fun cancelTimer(id: Long) = am.cancel(timerIntent(id))
fun scheduleBedtimeReminder(at: Long) =
am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, at, 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 upcomingIntent(id: Long) = broadcast(AlarmReceiver.ACTION_UPCOMING, id, REQ_UPCOMING)
private fun timerIntent(id: Long) = broadcast(AlarmReceiver.ACTION_TIMER, id, REQ_TIMER)
private fun broadcast(action: String, id: Long, base: Int): PendingIntent = PendingIntent.getBroadcast(
context, base + id.toInt(),
Intent(context, AlarmReceiver::class.java).setAction(action).putExtra(AlarmReceiver.EXTRA_ID, id),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
companion object {
const val UPCOMING_MS = 2 * 60 * 60 * 1000L
private const val REQ_RING = 0
private const val REQ_UPCOMING = 1_000_000
private const val REQ_TIMER = 2_000_000
private const val REQ_BEDTIME = 3_000_000
}
}
@@ -0,0 +1,195 @@
package app.uhr.data
import android.content.Context
import android.os.SystemClock
import app.uhr.alarm.Notifications
import app.uhr.alarm.Scheduler
import app.uhr.widget.updateClockWidgets
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.time.LocalDate
import java.time.ZonedDateTime
class ClockRepo(private val context: Context, private val dao: ClockDao, val prefs: Prefs) {
val scheduler = Scheduler(context, dao)
/** Ringing, snooze and dismiss can race (notification vs. screen); every transition goes through here. */
private val lock = Mutex()
// --- alarms -------------------------------------------------------------------------------
val alarms: Flow<List<AlarmWithInstance>> = dao.alarms()
val nextInstance: Flow<AlarmInstance?> = dao.nextInstance()
suspend fun alarm(id: Long) = dao.alarm(id)
suspend fun instance(id: Long) = dao.instance(id)
suspend fun nextInstanceOnce() = dao.nextInstanceOnce()
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) }
updateClockWidgets(context)
id
}
suspend fun delete(alarm: Alarm) = lock.withLock {
scheduler.cancel(alarm.id)
Notifications.cancelAlarm(context, alarm.id)
dao.delete(alarm)
updateClockWidgets(context)
}
/** Re-insert after "undo"; keeps the id so nothing else has to change. */
suspend fun restore(alarm: Alarm) = lock.withLock {
dao.insert(alarm)
scheduler.reschedule(alarm)
updateClockWidgets(context)
}
suspend fun snooze(id: Long) = lock.withLock {
val minutes = prefs.settingsOnce().snoozeMin
scheduler.snooze(id, minutes)
dao.alarm(id)?.let { a -> dao.instance(id)?.let { Notifications.snoozed(context, a, it.fireAt) } }
updateClockWidgets(context)
}
/** Stop: a one-time alarm switches off, a repeating one moves to its next day. */
suspend fun finishAlarm(id: Long, missed: Boolean = false) = lock.withLock {
val alarm = dao.alarm(id) ?: return@withLock
Notifications.cancelAlarm(context, id)
if (missed) Notifications.missed(context, alarm)
if (alarm.repeats) scheduler.reschedule(alarm)
else {
val off = alarm.copy(enabled = false, skipUntil = null)
dao.update(off); scheduler.reschedule(off)
}
updateClockWidgets(context)
}
/** Skip only the upcoming ring ("dismiss now" before it rings). */
suspend fun dismissNext(id: Long) = lock.withLock {
val alarm = dao.alarm(id) ?: return@withLock
val inst = dao.instance(id) ?: return@withLock
Notifications.cancelAlarm(context, id)
if (!alarm.repeats) { val off = alarm.copy(enabled = false); dao.update(off); scheduler.reschedule(off) }
else {
val after = java.time.Instant.ofEpochMilli(inst.fireAt).atZone(java.time.ZoneId.systemDefault()).plusMinutes(1)
scheduler.reschedule(alarm, after)
}
updateClockWidgets(context)
}
suspend fun markRinging(id: Long) = lock.withLock {
dao.instance(id)?.let { dao.putInstance(it.copy(state = InstanceState.RINGING, updatedAt = System.currentTimeMillis())) }
}
// --- timers -------------------------------------------------------------------------------
val timers: Flow<List<Timer>> = dao.timers()
suspend fun timer(id: Long) = dao.timer(id)
suspend fun startTimer(durationMs: Long, label: String = ""): Long {
val end = System.currentTimeMillis() + durationMs
val id = dao.insert(Timer(label = label, durationMs = durationMs, remainingMs = durationMs, endAt = end, state = TimerState.RUNNING))
dao.timer(id)?.let { scheduler.scheduleTimer(it); Notifications.timers(context, dao.timersOnce()) }
return id
}
suspend fun pauseTimer(id: Long) = updateTimer(id) { t ->
if (t.state != TimerState.RUNNING) t else t.copy(state = TimerState.PAUSED, remainingMs = t.remaining().coerceAtLeast(0), endAt = null)
}
suspend fun resumeTimer(id: Long) = updateTimer(id) { t ->
if (t.state != TimerState.PAUSED) t else t.copy(state = TimerState.RUNNING, endAt = System.currentTimeMillis() + t.remainingMs)
}
suspend fun addMinute(id: Long) = updateTimer(id) { t ->
when (t.state) {
TimerState.PAUSED -> t.copy(remainingMs = t.remainingMs + 60_000, durationMs = t.durationMs + 60_000)
// a ringing timer restarts with one minute
TimerState.RINGING -> t.copy(state = TimerState.RUNNING, endAt = System.currentTimeMillis() + 60_000, durationMs = 60_000, remainingMs = 60_000)
TimerState.RUNNING -> t.copy(endAt = (t.endAt ?: System.currentTimeMillis()) + 60_000, durationMs = t.durationMs + 60_000)
}
}
suspend fun resetTimer(id: Long) = updateTimer(id) { t ->
t.copy(state = TimerState.PAUSED, remainingMs = t.durationMs, endAt = null)
}
suspend fun setTimerLabel(id: Long, label: String) = updateTimer(id) { it.copy(label = label) }
suspend fun markTimerRinging(id: Long) = updateTimer(id) { it.copy(state = TimerState.RINGING) }
suspend fun deleteTimer(id: Long) {
scheduler.cancelTimer(id); dao.deleteTimer(id)
Notifications.timers(context, dao.timersOnce())
}
private suspend fun updateTimer(id: Long, f: (Timer) -> Timer) {
val t = dao.timer(id) ?: return
val n = f(t)
dao.update(n)
scheduler.cancelTimer(id)
if (n.state == TimerState.RUNNING) scheduler.scheduleTimer(n)
Notifications.timers(context, dao.timersOnce())
}
// --- stopwatch ----------------------------------------------------------------------------
val stopwatch: Flow<Stopwatch> = prefs.stopwatch
suspend fun stopwatchToggle() {
val s = prefs.stopwatch.first()
val now = SystemClock.elapsedRealtime()
val next = when (s.state) {
StopwatchState.RUNNING -> s.copy(state = StopwatchState.STOPPED, accumulatedMs = s.elapsed(now))
else -> s.copy(state = StopwatchState.RUNNING, startedAt = now)
}
prefs.setStopwatch(next)
Notifications.stopwatch(context, next)
}
suspend fun stopwatchLap() {
val s = prefs.stopwatch.first()
if (s.state != StopwatchState.RUNNING) return
prefs.setStopwatch(s.copy(laps = s.laps + s.elapsed(SystemClock.elapsedRealtime())))
}
suspend fun stopwatchReset() {
prefs.setStopwatch(Stopwatch())
Notifications.stopwatch(context, Stopwatch())
}
// --- world clock --------------------------------------------------------------------------
val cities: Flow<List<City>> = dao.cities()
suspend fun addCity(hit: ZoneHit) { dao.insert(City(zoneId = hit.zoneId, name = hit.city, sortOrder = dao.nextCityOrder())); updateClockWidgets(context) }
suspend fun deleteCity(id: Long) { dao.deleteCity(id); updateClockWidgets(context) }
suspend fun reorderCities(ids: List<Long>) = dao.reorderCities(ids)
// --- bedtime ------------------------------------------------------------------------------
val bedtime: Flow<Bedtime> = prefs.bedtime
/** Bedtime owns exactly one alarm (is_bedtime_wake) and one reminder. */
suspend fun saveBedtime(b: Bedtime) {
prefs.setBedtime(b)
val existing = dao.bedtimeAlarm()
val wake = (existing ?: Alarm(hour = b.wakeHour, minute = b.wakeMinute, isBedtimeWake = true))
.copy(hour = b.wakeHour, minute = b.wakeMinute, days = b.days, enabled = b.enabled, isBedtimeWake = true)
save(wake)
scheduleBedtime()
}
suspend fun scheduleBedtime() {
val b = prefs.bedtime.first()
scheduler.cancelBedtimeReminder()
if (!b.enabled || b.reminderMin < 0) return
val reminder = Alarm(hour = b.bedHour, minute = b.bedMinute, days = b.days)
val at = nextRing(reminder, ZonedDateTime.now().plusMinutes(b.reminderMin.toLong())).minusMinutes(b.reminderMin.toLong())
scheduler.scheduleBedtimeReminder(at.toInstant().toEpochMilli())
}
fun today(): LocalDate = LocalDate.now()
}
+81
View File
@@ -0,0 +1,81 @@
package app.uhr.data
import android.content.Context
import androidx.room.Dao
import androidx.room.Database
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.Transaction
import androidx.room.TypeConverter
import androidx.room.TypeConverters
import androidx.room.Update
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
import java.time.LocalDate
class Converters {
@TypeConverter fun fromDate(d: LocalDate?): String? = d?.toString()
@TypeConverter fun toDate(s: String?): LocalDate? = s?.let(LocalDate::parse)
@TypeConverter fun fromState(s: InstanceState): String = s.name.lowercase()
@TypeConverter fun toState(s: String): InstanceState = InstanceState.valueOf(s.uppercase())
@TypeConverter fun fromTimerState(s: TimerState): String = s.name.lowercase()
@TypeConverter fun toTimerState(s: String): TimerState = TimerState.valueOf(s.uppercase())
}
data class AlarmWithInstance(
@androidx.room.Embedded val alarm: Alarm,
@androidx.room.Relation(parentColumn = "id", entityColumn = "alarm_id") val instance: AlarmInstance?,
)
@Dao
interface ClockDao {
@Transaction
@Query("SELECT * FROM alarms ORDER BY is_bedtime_wake DESC, hour, minute, id")
fun alarms(): Flow<List<AlarmWithInstance>>
@Query("SELECT * FROM alarms") suspend fun alarmsOnce(): List<Alarm>
@Query("SELECT * FROM alarms WHERE id = :id") suspend fun alarm(id: Long): Alarm?
@Query("SELECT * FROM alarms WHERE is_bedtime_wake = 1 LIMIT 1") suspend fun bedtimeAlarm(): Alarm?
@Insert suspend fun insert(alarm: Alarm): Long
@Update suspend fun update(alarm: Alarm)
@Delete suspend fun delete(alarm: Alarm)
@Query("SELECT * FROM alarm_instances WHERE alarm_id = :id") suspend fun instance(id: Long): AlarmInstance?
@Query("SELECT * FROM alarm_instances") suspend fun instancesOnce(): List<AlarmInstance>
@Upsert suspend fun putInstance(instance: AlarmInstance)
@Query("DELETE FROM alarm_instances WHERE alarm_id = :id") suspend fun deleteInstance(id: Long)
/** The soonest alarm that will actually ring, for widgets and the clock header. */
@Query("SELECT * FROM alarm_instances WHERE state IN ('scheduled','snoozed') ORDER BY fire_at LIMIT 1")
fun nextInstance(): Flow<AlarmInstance?>
@Query("SELECT * FROM alarm_instances WHERE state IN ('scheduled','snoozed') ORDER BY fire_at LIMIT 1")
suspend fun nextInstanceOnce(): AlarmInstance?
@Query("SELECT * FROM timers ORDER BY created_at") fun timers(): Flow<List<Timer>>
@Query("SELECT * FROM timers ORDER BY created_at") suspend fun timersOnce(): List<Timer>
@Query("SELECT * FROM timers WHERE id = :id") suspend fun timer(id: Long): Timer?
@Insert suspend fun insert(timer: Timer): Long
@Update suspend fun update(timer: Timer)
@Query("DELETE FROM timers WHERE id = :id") suspend fun deleteTimer(id: Long)
@Query("SELECT * FROM cities ORDER BY sort_order, id") fun cities(): Flow<List<City>>
@Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun insert(city: City): Long
@Query("DELETE FROM cities WHERE id = :id") suspend fun deleteCity(id: Long)
@Query("SELECT COALESCE(MAX(sort_order), 0) + 1 FROM cities") suspend fun nextCityOrder(): Int
@Query("UPDATE cities SET sort_order = :order WHERE id = :id") suspend fun setCityOrder(id: Long, order: Int)
@Transaction suspend fun reorderCities(ids: List<Long>) = ids.forEachIndexed { i, id -> setCityOrder(id, i) }
}
@Database(entities = [Alarm::class, AlarmInstance::class, Timer::class, City::class], version = 1, exportSchema = true)
@TypeConverters(Converters::class)
abstract class ClockDb : RoomDatabase() {
abstract fun dao(): ClockDao
companion object {
fun create(context: Context) = Room.databaseBuilder(context, ClockDb::class.java, "clock.db").build()
}
}
@@ -0,0 +1,13 @@
package app.uhr.data
import android.content.Context
object Graph {
lateinit var repo: ClockRepo private set
fun init(context: Context) {
if (::repo.isInitialized) return
val app = context.applicationContext
repo = ClockRepo(app, ClockDb.create(app).dao(), Prefs(app))
}
}
@@ -0,0 +1,82 @@
package app.uhr.data
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import java.time.DayOfWeek
import java.time.LocalDate
@Entity(tableName = "alarms", indices = [Index("hour", "minute")])
data class Alarm(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val hour: Int,
val minute: Int,
val enabled: Boolean = true,
/** bit 0 = Monday … bit 6 = Sunday; 0 = rings once */
val days: Int = 0,
val label: String = "",
/** null = default sound, [SILENT] = no sound */
@ColumnInfo(name = "ringtone_uri") val ringtoneUri: String? = null,
val vibrate: Boolean = true,
/** paused until and including this date */
@ColumnInfo(name = "skip_until") val skipUntil: LocalDate? = null,
@ColumnInfo(name = "is_bedtime_wake") val isBedtimeWake: Boolean = false,
@ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis(),
@ColumnInfo(name = "updated_at") val updatedAt: Long = System.currentTimeMillis(),
) {
val repeats: Boolean get() = days != 0
fun ringsOn(day: DayOfWeek): Boolean = days and (1 shl (day.value - 1)) != 0
fun withDay(day: DayOfWeek, on: Boolean): Alarm =
copy(days = if (on) days or (1 shl (day.value - 1)) else days and (1 shl (day.value - 1)).inv())
companion object {
const val SILENT = "silent"
const val WEEKDAYS = 0b0011111
const val EVERY_DAY = 0b1111111
}
}
enum class InstanceState { SCHEDULED, SNOOZED, RINGING, MISSED, DISMISSED }
/** The next concrete ring of an alarm; snooze and "dismiss early" live here. */
@Entity(
tableName = "alarm_instances",
foreignKeys = [ForeignKey(Alarm::class, ["id"], ["alarm_id"], onDelete = ForeignKey.CASCADE)],
indices = [Index("state", "fire_at")],
)
data class AlarmInstance(
@PrimaryKey @ColumnInfo(name = "alarm_id") val alarmId: Long,
@ColumnInfo(name = "fire_at") val fireAt: Long,
val state: InstanceState = InstanceState.SCHEDULED,
@ColumnInfo(name = "updated_at") val updatedAt: Long = System.currentTimeMillis(),
)
enum class TimerState { RUNNING, PAUSED, RINGING }
@Entity(tableName = "timers", indices = [Index("state", "end_at")])
data class Timer(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val label: String = "",
@ColumnInfo(name = "duration_ms") val durationMs: Long,
/** valid while paused */
@ColumnInfo(name = "remaining_ms") val remainingMs: Long,
/** set while running or ringing */
@ColumnInfo(name = "end_at") val endAt: Long?,
val state: TimerState,
@ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis(),
) {
fun remaining(now: Long = System.currentTimeMillis()): Long = when (state) {
TimerState.PAUSED -> remainingMs
else -> (endAt ?: now) - now
}
}
@Entity(tableName = "cities", indices = [Index(value = ["zone_id"], unique = true), Index("sort_order")])
data class City(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
@ColumnInfo(name = "zone_id") val zoneId: String,
val name: String,
@ColumnInfo(name = "sort_order") val sortOrder: Int = 0,
)
@@ -0,0 +1,24 @@
package app.uhr.data
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZonedDateTime
/**
* When an alarm rings next, strictly after [now]. Wall-clock based: 7:00 stays 7:00 across
* time zone and DST changes. A time that does not exist (spring forward) rings at the first valid
* instant after it, which is what ZonedDateTime.of does.
*/
fun nextRing(alarm: Alarm, now: ZonedDateTime): ZonedDateTime {
val zone: ZoneId = now.zone
var date = now.toLocalDate()
val skipUntil = alarm.skipUntil
repeat(400) {
val candidate = ZonedDateTime.of(LocalDateTime.of(date, java.time.LocalTime.of(alarm.hour, alarm.minute)), zone)
val dayOk = !alarm.repeats || alarm.ringsOn(date.dayOfWeek)
val notSkipped = skipUntil == null || date.isAfter(skipUntil)
if (candidate.isAfter(now) && dayOk && notSkipped) return candidate
date = date.plusDays(1)
}
error("no next ring for $alarm")
}
+158
View File
@@ -0,0 +1,158 @@
package app.uhr.data
import android.content.Context
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
enum class ThemeMode { SYSTEM, LIGHT, DARK }
enum class VolumeButtons { VOLUME, SNOOZE, STOP, NOTHING }
enum class ClockStyle { DIGITAL, ANALOG }
data class Settings(
val silenceAfterMin: Int = 10,
val snoozeMin: Int = 10,
/** 0..100, share of the alarm stream's max volume */
val alarmVolume: Int = 80,
/** seconds to ramp up from quiet; 0 = off */
val gradualVolumeS: Int = 30,
val volumeButtons: VolumeButtons = VolumeButtons.VOLUME,
/** java.time.DayOfWeek value, 1 = Monday */
val weekStart: Int = 1,
val clockStyle: ClockStyle = ClockStyle.DIGITAL,
val showSeconds: Boolean = false,
val autoHomeClock: Boolean = true,
val homeZone: String? = null,
val timerSound: String? = null,
val timerVibrate: Boolean = true,
val theme: ThemeMode = ThemeMode.SYSTEM,
)
enum class StopwatchState { IDLE, RUNNING, STOPPED }
/** Timestamps only (elapsedRealtime), never a running counter, so process death changes nothing. */
data class Stopwatch(
val state: StopwatchState = StopwatchState.IDLE,
val startedAt: Long = 0,
val accumulatedMs: Long = 0,
val laps: List<Long> = emptyList(),
) {
fun elapsed(now: Long): Long = accumulatedMs + if (state == StopwatchState.RUNNING) now - startedAt else 0
}
data class Bedtime(
val enabled: Boolean = false,
val bedHour: Int = 23,
val bedMinute: Int = 0,
val wakeHour: Int = 7,
val wakeMinute: Int = 0,
val days: Int = Alarm.WEEKDAYS,
val reminderMin: Int = 15,
)
private val Context.store by preferencesDataStore("clock")
class Prefs(private val context: Context) {
private object K {
val silence = intPreferencesKey("silence_after")
val snooze = intPreferencesKey("snooze")
val volume = intPreferencesKey("alarm_volume")
val gradual = intPreferencesKey("gradual")
val buttons = stringPreferencesKey("volume_buttons")
val weekStart = intPreferencesKey("week_start")
val style = stringPreferencesKey("clock_style")
val seconds = booleanPreferencesKey("seconds")
val autoHome = booleanPreferencesKey("auto_home")
val homeZone = stringPreferencesKey("home_zone")
val timerSound = stringPreferencesKey("timer_sound")
val timerVibrate = booleanPreferencesKey("timer_vibrate")
val theme = stringPreferencesKey("theme")
val swState = stringPreferencesKey("sw_state")
val swStarted = longPreferencesKey("sw_started")
val swAcc = longPreferencesKey("sw_acc")
val swLaps = stringPreferencesKey("sw_laps")
val bedOn = booleanPreferencesKey("bed_on")
val bedH = intPreferencesKey("bed_h")
val bedM = intPreferencesKey("bed_m")
val wakeH = intPreferencesKey("wake_h")
val wakeM = intPreferencesKey("wake_m")
val bedDays = intPreferencesKey("bed_days")
val bedReminder = intPreferencesKey("bed_reminder")
}
private fun Preferences.settings(): Settings {
val d = Settings()
return Settings(
silenceAfterMin = this[K.silence] ?: d.silenceAfterMin,
snoozeMin = this[K.snooze] ?: d.snoozeMin,
alarmVolume = this[K.volume] ?: d.alarmVolume,
gradualVolumeS = this[K.gradual] ?: d.gradualVolumeS,
volumeButtons = this[K.buttons].enumOr(d.volumeButtons),
weekStart = this[K.weekStart] ?: d.weekStart,
clockStyle = this[K.style].enumOr(d.clockStyle),
showSeconds = this[K.seconds] ?: d.showSeconds,
autoHomeClock = this[K.autoHome] ?: d.autoHomeClock,
homeZone = this[K.homeZone],
timerSound = this[K.timerSound],
timerVibrate = this[K.timerVibrate] ?: d.timerVibrate,
theme = this[K.theme].enumOr(d.theme),
)
}
val settings: Flow<Settings> = context.store.data.map { it.settings() }
suspend fun settingsOnce(): Settings = settings.first()
suspend fun update(transform: (Settings) -> Settings) {
context.store.edit { p ->
val s = transform(p.settings())
p[K.silence] = s.silenceAfterMin; p[K.snooze] = s.snoozeMin; p[K.volume] = s.alarmVolume
p[K.gradual] = s.gradualVolumeS; p[K.buttons] = s.volumeButtons.name; p[K.weekStart] = s.weekStart
p[K.style] = s.clockStyle.name; p[K.seconds] = s.showSeconds; p[K.autoHome] = s.autoHomeClock
if (s.homeZone != null) p[K.homeZone] = s.homeZone else p.remove(K.homeZone)
if (s.timerSound != null) p[K.timerSound] = s.timerSound else p.remove(K.timerSound)
p[K.timerVibrate] = s.timerVibrate; p[K.theme] = s.theme.name
}
}
val stopwatch: Flow<Stopwatch> = context.store.data.map { p ->
Stopwatch(
state = p[K.swState].enumOr(StopwatchState.IDLE),
startedAt = p[K.swStarted] ?: 0,
accumulatedMs = p[K.swAcc] ?: 0,
laps = p[K.swLaps]?.split(',')?.filter { it.isNotBlank() }?.map { it.toLong() } ?: emptyList(),
)
}
suspend fun setStopwatch(s: Stopwatch) {
context.store.edit { p ->
p[K.swState] = s.state.name; p[K.swStarted] = s.startedAt; p[K.swAcc] = s.accumulatedMs
p[K.swLaps] = s.laps.joinToString(",")
}
}
val bedtime: Flow<Bedtime> = context.store.data.map { p ->
val d = Bedtime()
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,
)
}
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
}
}
}
private inline fun <reified E : Enum<E>> String?.enumOr(default: E): E =
this?.let { runCatching { enumValueOf<E>(it) }.getOrNull() } ?: default
@@ -0,0 +1,34 @@
package app.uhr.data
import java.time.ZoneId
import java.util.Locale
data class ZoneHit(val zoneId: String, val city: String, val region: String)
/** Offline city search over the system time zone database; names localized by ICU. */
object Zones {
private val all: List<ZoneHit> by lazy {
ZoneId.getAvailableZoneIds()
.filter { it.contains('/') && !it.startsWith("Etc/") && !it.startsWith("SystemV/") && it.first().isUpperCase() }
.mapNotNull { id ->
val parts = id.split('/')
val city = cityName(id) ?: parts.last().replace('_', ' ')
ZoneHit(id, city, parts.first())
}
.distinctBy { it.city to it.region }
.sortedBy { it.city }
}
fun cityName(zoneId: String, locale: Locale = Locale.getDefault()): String? =
runCatching {
android.icu.text.TimeZoneNames.getInstance(android.icu.util.ULocale.forLocale(locale)).getExemplarLocationName(zoneId)
}.getOrNull()?.takeIf { it.isNotBlank() }
fun search(query: String): List<ZoneHit> {
val q = query.trim()
if (q.isEmpty()) return all.take(50)
return all.filter { it.city.contains(q, ignoreCase = true) || it.zoneId.contains(q.replace(' ', '_'), ignoreCase = true) }
.sortedBy { if (it.city.startsWith(q, ignoreCase = true)) 0 else 1 }
.take(50)
}
}
@@ -0,0 +1,38 @@
package app.uhr.ui.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.unit.Dp
import java.time.ZonedDateTime
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
/** Our own face: filled disc, thick rounded hour hand, long minute hand, optional second hand. */
@Composable
fun AnalogClock(time: ZonedDateTime, size: Dp, face: Color, hourHand: Color, minuteHand: Color, secondHand: Color?, modifier: Modifier = Modifier) {
Canvas(modifier.size(size)) { drawAnalog(time.hour, time.minute, time.second, face, hourHand, minuteHand, secondHand) }
}
fun DrawScope.drawAnalog(h: Int, m: Int, s: Int, face: Color, hourHand: Color, minuteHand: Color, secondHand: Color?) {
val r = size.minDimension / 2
val c = center
drawCircle(face, r, c)
for (i in 0 until 12) {
val a = 2 * PI * i / 12
drawCircle(minuteHand.copy(alpha = .35f), r * .025f, Offset(c.x + r * .86f * sin(a).toFloat(), c.y - r * .86f * cos(a).toFloat()))
}
fun hand(angle: Double, length: Float, width: Float, color: Color) {
drawLine(color, c, Offset(c.x + length * sin(angle).toFloat(), c.y - length * cos(angle).toFloat()), width, StrokeCap.Round)
}
hand(2 * PI * ((h % 12) + m / 60.0) / 12, r * .5f, r * .11f, hourHand)
hand(2 * PI * (m + s / 60.0) / 60, r * .74f, r * .06f, minuteHand)
if (secondHand != null) hand(2 * PI * s / 60, r * .8f, r * .02f, secondHand)
drawCircle(minuteHand, r * .06f, c)
}
@@ -0,0 +1,102 @@
package app.uhr.ui.components
import android.content.res.Resources
import android.text.format.DateFormat
import app.uhr.R
import java.time.DayOfWeek
import java.time.Duration
import java.time.Instant
import java.time.LocalDate
import java.time.LocalTime
import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.time.format.TextStyle
import java.util.Locale
import kotlin.math.abs
object Fmt {
/** Set once from the system setting (DateFormat.is24HourFormat). */
var is24h: Boolean = true
fun time(h: Int, m: Int): String =
if (is24h) String.format(Locale.getDefault(), "%d:%02d", h, m)
else String.format(Locale.getDefault(), "%d:%02d", if (h % 12 == 0) 12 else h % 12, m)
fun ampm(h: Int): String? = if (is24h) null else if (h < 12) "AM" else "PM"
fun clock(at: Long, zone: ZoneId = ZoneId.systemDefault(), seconds: Boolean = false): String {
val t = Instant.ofEpochMilli(at).atZone(zone)
val base = time(t.hour, t.minute)
return if (seconds) base + String.format(Locale.getDefault(), ":%02d", t.second) else base
}
fun longDate(at: Long): String =
Instant.ofEpochMilli(at).atZone(ZoneId.systemDefault()).format(DateTimeFormatter.ofPattern("EEE, d. MMMM", Locale.getDefault()))
fun dayShort(d: DayOfWeek): String = d.getDisplayName(TextStyle.SHORT, Locale.getDefault()).trimEnd('.')
fun dayNarrow(d: DayOfWeek): String = d.getDisplayName(TextStyle.NARROW, Locale.getDefault())
fun dayFull(d: DayOfWeek): String = d.getDisplayName(TextStyle.FULL, Locale.getDefault())
/** mm:ss or h:mm:ss */
fun duration(ms: Long): String {
val s = abs(ms) / 1000
val h = s / 3600; val m = s / 60 % 60; val sec = s % 60
return if (h > 0) String.format(Locale.getDefault(), "%d:%02d:%02d", h, m, sec) else String.format(Locale.getDefault(), "%d:%02d", m, sec)
}
/** mm:ss.cc, with hours when needed */
fun stopwatch(ms: Long): String {
val cs = ms / 10 % 100; val s = ms / 1000
val h = s / 3600; val m = s / 60 % 60; val sec = s % 60
return if (h > 0) String.format(Locale.getDefault(), "%d:%02d:%02d.%02d", h, m, sec, cs)
else String.format(Locale.getDefault(), "%02d:%02d.%02d", m, sec, cs)
}
/** "in 7 Std. 12 Min." */
fun until(res: Resources, fireAt: Long, now: Long = System.currentTimeMillis()): String {
val mins = ((fireAt - now + 59_999) / 60_000).coerceAtLeast(0)
val d = mins / 1440; val h = mins / 60 % 24; val m = mins % 60
return when {
d > 0 -> res.getString(R.string.in_days_hours, d, h)
h > 0 -> res.getString(R.string.in_hours_minutes, h, m)
else -> res.getString(R.string.in_minutes, m)
}
}
/** "Heute", "Morgen", or the weekday, for the next ring. */
fun ringDay(res: Resources, fireAt: Long): String {
val d = Instant.ofEpochMilli(fireAt).atZone(ZoneId.systemDefault()).toLocalDate()
val today = LocalDate.now()
return when (d) {
today -> res.getString(R.string.today)
today.plusDays(1) -> res.getString(R.string.tomorrow)
else -> d.format(DateTimeFormatter.ofPattern("EEE, d. MMM", Locale.getDefault()))
}
}
/** Repeat summary: "Jeden Tag", "Werktags", "Mo, Mi, Fr". */
fun days(res: Resources, days: Int, weekStart: Int): String = when (days) {
0 -> ""
0b1111111 -> res.getString(R.string.every_day)
0b0011111 -> res.getString(R.string.weekdays)
0b1100000 -> res.getString(R.string.weekends)
else -> orderedDays(weekStart).filter { days and (1 shl (it.value - 1)) != 0 }.joinToString(", ") { dayShort(it) }
}
fun orderedDays(weekStart: Int): List<DayOfWeek> = (0 until 7).map { DayOfWeek.of((weekStart - 1 + it) % 7 + 1) }
/** "+7 Std." / "−1:30 Std." relative to local time */
fun offset(res: Resources, zone: ZoneId, now: Long = System.currentTimeMillis()): String {
val i = Instant.ofEpochMilli(now)
val diff = Duration.ofSeconds((zone.rules.getOffset(i).totalSeconds - ZoneId.systemDefault().rules.getOffset(i).totalSeconds).toLong())
if (diff.isZero) return res.getString(R.string.same_time)
val sign = if (diff.isNegative) "−" else "+"
val a = diff.abs()
val txt = if (a.toMinutesPart() == 0) "${a.toHours()}" else String.format(Locale.getDefault(), "%d:%02d", a.toHours(), a.toMinutesPart())
val there = i.atZone(zone).toLocalDate(); val here = i.atZone(ZoneId.systemDefault()).toLocalDate()
val day = when { there.isAfter(here) -> res.getString(R.string.tomorrow) + " · "; there.isBefore(here) -> res.getString(R.string.yesterday) + " · "; else -> "" }
return day + res.getString(R.string.hours_offset, sign + txt)
}
}
@@ -0,0 +1,161 @@
package app.uhr.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import app.uhr.ui.theme.WSpace
import app.uhr.ui.theme.WType
/*
* Settings in the style of current Pixel system settings: grouped tiles with large outer
* corners and small inner ones, 2dp gaps, tonal icon circles, values as summaries,
* choices in dialogs.
*/
private val Outer = 24.dp
private val Inner = 4.dp
private fun tileShape(index: Int, count: Int): Shape = when {
count == 1 -> RoundedCornerShape(Outer)
index == 0 -> RoundedCornerShape(topStart = Outer, topEnd = Outer, bottomStart = Inner, bottomEnd = Inner)
index == count - 1 -> RoundedCornerShape(topStart = Inner, topEnd = Inner, bottomStart = Outer, bottomEnd = Outer)
else -> RoundedCornerShape(Inner)
}
class GroupScope internal constructor() {
internal val items = mutableListOf<@Composable (Shape) -> Unit>()
fun item(content: @Composable (Shape) -> Unit) { items += content }
}
@Composable
fun SettingsGroup(title: String? = null, modifier: Modifier = Modifier, content: GroupScope.() -> Unit) {
val scope = GroupScope().apply(content)
Column(modifier.padding(horizontal = WSpace.s16)) {
if (title != null) {
Text(
title, style = WType.label.copy(fontSize = WType.bodySm.fontSize), color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = WSpace.s16, top = WSpace.s24, bottom = WSpace.s8).semantics { heading() },
)
} else Spacer(Modifier.padding(top = WSpace.s16))
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
scope.items.forEachIndexed { i, item -> item(tileShape(i, scope.items.size)) }
}
}
}
@Composable
private fun Tile(shape: Shape, modifier: Modifier = Modifier, content: @Composable () -> Unit) {
Box(
modifier.fillMaxWidth().clip(shape).background(MaterialTheme.colorScheme.surfaceContainerLowest).heightIn(min = 72.dp),
contentAlignment = Alignment.CenterStart,
) { content() }
}
@Composable
private fun RowContent(icon: ImageVector?, title: String, summary: String?, trailing: (@Composable () -> Unit)?) {
Row(Modifier.fillMaxWidth().padding(horizontal = WSpace.s16, vertical = WSpace.s16), verticalAlignment = Alignment.CenterVertically) {
if (icon != null) {
Box(Modifier.size(40.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primaryContainer), contentAlignment = Alignment.Center) {
Icon(icon, null, tint = MaterialTheme.colorScheme.onPrimaryContainer, modifier = Modifier.size(22.dp))
}
Spacer(Modifier.width(WSpace.s16))
}
Column(Modifier.weight(1f)) {
Text(title, style = WType.body, color = MaterialTheme.colorScheme.onSurface)
if (summary != null) Text(summary, style = WType.bodySm, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
if (trailing != null) { Spacer(Modifier.width(WSpace.s12)); trailing() }
}
}
@Composable
fun SettingsItem(shape: Shape, title: String, summary: String? = null, icon: ImageVector? = null, onClick: (() -> Unit)? = null, trailing: (@Composable () -> Unit)? = null) {
Tile(shape, if (onClick != null) Modifier.clip(shape).clickable(onClick = onClick) else Modifier) { RowContent(icon, title, summary, trailing) }
}
@Composable
fun SettingsSwitch(shape: Shape, title: String, checked: Boolean, onChange: (Boolean) -> Unit, summary: String? = null, icon: ImageVector? = null) {
Tile(shape, Modifier.clip(shape).toggleable(checked, role = Role.Switch, onValueChange = onChange)) {
RowContent(icon, title, summary) {
Switch(
checked, onCheckedChange = null,
thumbContent = if (checked) { { Icon(Icons.Rounded.Check, null, Modifier.size(SwitchDefaults.IconSize)) } } else null,
)
}
}
}
/** A tile whose value is picked from a list in a dialog, like Pixel's radio settings. */
@Composable
fun <T> SettingsChoice(shape: Shape, title: String, options: List<T>, selected: T, label: @Composable (T) -> String, onPick: (T) -> Unit, icon: ImageVector? = null) {
var open by remember { mutableStateOf(false) }
SettingsItem(shape, title, label(selected), icon, onClick = { open = true })
if (open) {
AlertDialog(
onDismissRequest = { open = false },
title = { Text(title) },
text = {
Column(Modifier.selectableGroup()) {
options.forEach { o ->
Row(
Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp))
.selectable(o == selected, role = Role.RadioButton) { onPick(o); open = false }
.padding(vertical = WSpace.s12, horizontal = WSpace.s4),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(o == selected, onClick = null)
Spacer(Modifier.width(WSpace.s16))
Text(label(o), style = WType.body)
}
}
}
},
confirmButton = { TextButton(onClick = { open = false }) { Text(androidx.compose.ui.res.stringResource(android.R.string.cancel)) } },
)
}
}
/** Free content inside a group tile (swatches, sliders). */
@Composable
fun SettingsCustom(shape: Shape, content: @Composable ColumnScope.() -> Unit) {
Tile(shape) { Column(Modifier.fillMaxWidth().padding(WSpace.s16), content = content) }
}
@@ -0,0 +1,378 @@
package app.uhr.ui.screens
import android.content.Intent
import android.media.RingtoneManager
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
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.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AlarmOff
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Label
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.ExpandLess
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TimeInput
import androidx.compose.material3.TimePicker
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.material3.rememberTimePickerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import app.uhr.R
import app.uhr.data.Alarm
import app.uhr.data.AlarmWithInstance
import app.uhr.data.InstanceState
import app.uhr.data.Settings
import app.uhr.ui.components.Fmt
import app.uhr.ui.theme.WRadius
import app.uhr.ui.theme.WSpace
import app.uhr.ui.theme.WType
import app.uhr.ui.theme.tabular
import kotlinx.coroutines.launch
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.ZoneOffset
/** Actions the alarm screen needs; MainActivity passes the repository, tests pass fakes. */
interface AlarmActions {
fun save(alarm: Alarm)
fun delete(alarm: Alarm)
fun restore(alarm: Alarm)
fun dismissNext(alarm: Alarm)
}
/** S01 + S02: list of alarms, each card expands to edit in place. */
@Composable
fun AlarmScreen(
alarms: List<AlarmWithInstance>,
settings: Settings,
actions: AlarmActions,
snackbar: SnackbarHostState,
contentPadding: PaddingValues,
initiallyExpanded: Long? = null,
) {
var expanded by rememberSaveable { mutableStateOf(initiallyExpanded) }
val scope = rememberCoroutineScope()
val res = LocalContext.current.resources
if (alarms.isEmpty()) {
Column(Modifier.fillMaxSize().padding(contentPadding), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
Icon(Icons.Outlined.AlarmOff, null, Modifier.size(56.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.height(WSpace.s12))
Text(stringResource(R.string.no_alarms), style = WType.body, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
return
}
val bedtime = alarms.filter { it.alarm.isBedtimeWake }
val others = alarms.filter { !it.alarm.isBedtimeWake }
LazyColumn(
Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = WSpace.s16, end = WSpace.s16, top = contentPadding.calculateTopPadding(), bottom = contentPadding.calculateBottomPadding() + 120.dp),
verticalArrangement = Arrangement.spacedBy(WSpace.s8),
) {
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)
}
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,
onDelete = {
actions.delete(a.alarm)
expanded = null
scope.launch {
val r = snackbar.showSnackbar(res.getString(R.string.alarm_deleted), res.getString(R.string.undo), duration = SnackbarDuration.Short)
if (r == SnackbarResult.ActionPerformed) actions.restore(a.alarm)
}
},
)
}
}
}
@Composable
private fun SectionHeader(text: String) =
Text(text, style = WType.label, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = WSpace.s8, top = WSpace.s12, bottom = WSpace.s4))
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AlarmCard(
item: AlarmWithInstance,
settings: Settings,
expanded: Boolean,
onToggleExpand: () -> Unit,
actions: AlarmActions,
onDelete: (() -> Unit)?,
) {
val a = item.alarm
val inst = item.instance
val res = LocalContext.current.resources
val scheme = MaterialTheme.colorScheme
var pickTime by remember { mutableStateOf(false) }
var pickSound by remember { mutableStateOf(false) }
var pickPause by remember { mutableStateOf(false) }
val timeColor = if (a.enabled) scheme.onSurface else scheme.onSurfaceVariant.copy(alpha = .7f)
val status = when {
!a.enabled -> if (a.repeats) Fmt.days(res, a.days, settings.weekStart) else stringResource(R.string.not_scheduled)
inst?.state == InstanceState.SNOOZED -> stringResource(R.string.snoozed_until, Fmt.clock(inst.fireAt))
a.skipUntil != null && inst != null -> stringResource(R.string.paused_until, Fmt.ringDay(res, inst.fireAt))
a.repeats -> Fmt.days(res, a.days, settings.weekStart)
inst != null -> Fmt.ringDay(res, inst.fireAt)
else -> ""
}
Column(
Modifier.fillMaxWidth().clip(RoundedCornerShape(WRadius.card)).background(scheme.surfaceContainerLow)
.clickable(onClick = onToggleExpand).padding(WSpace.s20).animateContentSize(spring(dampingRatio = .85f, stiffness = 380f))
.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))
}
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)
}
}
Spacer(Modifier.height(WSpace.s8))
Row(Modifier.clip(RoundedCornerShape(WRadius.sm)).clickable { pickTime = true }, verticalAlignment = Alignment.Bottom) {
Text(Fmt.time(a.hour, a.minute), style = tabular(WType.alarmTime), color = timeColor)
Fmt.ampm(a.hour)?.let { Text(" $it", style = WType.alarmAmpm, color = timeColor, modifier = Modifier.padding(bottom = 6.dp)) }
}
Spacer(Modifier.height(WSpace.s8))
Row(verticalAlignment = Alignment.CenterVertically) {
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) {
Column {
Spacer(Modifier.height(WSpace.s12))
DayChips(a, settings.weekStart) { actions.save(it) }
Spacer(Modifier.height(WSpace.s8))
if (a.repeats) OptionRow(Icons.Outlined.PauseCircle, if (a.skipUntil != null) stringResource(R.string.paused_until_date, a.skipUntil.toString()) else stringResource(R.string.pause_alarm)) { pickPause = true }
if (inst != null && a.enabled && inst.state == InstanceState.SCHEDULED) OptionRow(Icons.Outlined.AlarmOff, stringResource(R.string.skip_next)) { actions.dismissNext(a) }
OptionRow(Icons.Outlined.NotificationsActive, soundName(a.ringtoneUri)) { pickSound = true }
OptionRow(Icons.Outlined.Vibration, stringResource(R.string.vibrate), trailing = { if (a.vibrate) Icon(Icons.Rounded.Check, null, tint = scheme.primary) }) {
actions.save(a.copy(vibrate = !a.vibrate))
}
if (onDelete != null) OptionRow(Icons.Outlined.Delete, stringResource(R.string.delete), onClick = onDelete)
}
}
}
if (pickTime) TimeDialog(a.hour, a.minute, onDismiss = { pickTime = false }) { h, m -> pickTime = false; actions.save(a.copy(hour = h, minute = m, enabled = true)) }
if (pickSound) SoundDialog(a.ringtoneUri, onDismiss = { pickSound = false }) { uri -> pickSound = false; actions.save(a.copy(ringtoneUri = uri)) }
if (pickPause) {
val state = rememberDatePickerState(
initialSelectedDateMillis = (a.skipUntil ?: LocalDate.now().plusDays(1)).atStartOfDay().toInstant(ZoneOffset.UTC).toEpochMilli(),
)
DatePickerDialog(
onDismissRequest = { pickPause = false },
confirmButton = {
TextButton(onClick = {
pickPause = false
state.selectedDateMillis?.let { actions.save(a.copy(skipUntil = Instant.ofEpochMilli(it).atZone(ZoneOffset.UTC).toLocalDate(), enabled = true)) }
}) { Text(stringResource(android.R.string.ok)) }
},
dismissButton = {
TextButton(onClick = { pickPause = false; actions.save(a.copy(skipUntil = null)) }) { Text(stringResource(R.string.resume)) }
},
) { DatePicker(state, title = { Text(stringResource(R.string.pause_until), Modifier.padding(start = 24.dp, top = 16.dp)) }) }
}
}
@Composable
private fun androidx.compose.foundation.layout.RowScope.LabelField(value: String, onDone: (String) -> Unit) {
var text by remember(value) { mutableStateOf(value) }
TextField(
text, { text = it }, Modifier.weight(1f),
placeholder = { Text(stringResource(R.string.add_label)) },
leadingIcon = { Icon(Icons.Outlined.Label, null) },
singleLine = true, textStyle = WType.body,
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,
),
)
androidx.compose.runtime.DisposableEffect(Unit) { onDispose { if (text.trim() != value) onDone(text.trim()) } }
}
@Composable
private fun DayChips(a: Alarm, weekStart: Int, onChange: (Alarm) -> Unit) {
val scheme = MaterialTheme.colorScheme
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Fmt.orderedDays(weekStart).forEach { d ->
val on = a.ringsOn(d)
Box(
Modifier.size(38.dp).clip(CircleShape)
.background(if (on) scheme.primary else androidx.compose.ui.graphics.Color.Transparent)
.border(1.dp, if (on) scheme.primary else scheme.outline, CircleShape)
.clickable(role = Role.Checkbox) { onChange(a.withDay(d, !on)) }
.semantics { contentDescription = Fmt.dayFull(d) },
contentAlignment = Alignment.Center,
) { Text(Fmt.dayNarrow(d), style = WType.label, color = if (on) scheme.onPrimary else scheme.onSurface) }
}
}
}
@Composable
private fun OptionRow(icon: ImageVector, text: String, trailing: @Composable (() -> Unit)? = null, onClick: () -> Unit) {
Row(
Modifier.fillMaxWidth().clip(RoundedCornerShape(WRadius.sm)).clickable(onClick = onClick).padding(vertical = WSpace.s12, horizontal = WSpace.s4),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(icon, null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.width(WSpace.s16))
Text(text, style = WType.bodySm, modifier = Modifier.weight(1f))
trailing?.invoke()
}
}
@Composable
fun soundName(uri: String?): String {
val context = LocalContext.current
return when (uri) {
null -> stringResource(R.string.sound_default)
Alarm.SILENT -> stringResource(R.string.sound_silent)
else -> remember(uri) {
runCatching { RingtoneManager.getRingtone(context, Uri.parse(uri))?.getTitle(context) }.getOrNull()
?: Uri.parse(uri).lastPathSegment ?: uri
}
}
}
/** S03: M3 time picker with dial/keyboard switch. */
@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) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.pick_time)) },
text = {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
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)) } },
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } },
)
}
/** S04: system alarm tones, the bundled one, silence, or an own file (persisted read permission). */
@Composable
fun SoundDialog(current: String?, onDismiss: () -> Unit, onPick: (String?) -> Unit) {
val context = LocalContext.current
val tones = remember {
val list = mutableListOf<Pair<String, String>>()
runCatching {
val rm = RingtoneManager(context).apply { setType(RingtoneManager.TYPE_ALARM) }
val c = rm.cursor
while (c.moveToNext()) list += rm.getRingtoneUri(c.position).toString() to c.getString(RingtoneManager.TITLE_COLUMN_INDEX)
}
list
}
var preview by remember { mutableStateOf<android.media.Ringtone?>(null) }
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() }
}
val file = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
if (uri != null) {
runCatching { context.contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) }
onPick(uri.toString())
}
}
var selected by remember { mutableStateOf(current) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.alarm_sound)) },
text = {
LazyColumn(Modifier.height(420.dp)) {
item { TextButton(onClick = { file.launch(arrayOf("audio/*")) }) { Text(stringResource(R.string.add_own_sound)) } }
val rows = listOf<Pair<String?, String>>(null to context.getString(R.string.sound_default), Alarm.SILENT to context.getString(R.string.sound_silent)) + tones
items(rows) { (uri, name) ->
Row(
Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)).clickable { selected = uri; play(uri) }.padding(vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(selected == uri, onClick = null)
Spacer(Modifier.width(WSpace.s12))
Text(name, style = WType.body)
}
}
}
},
confirmButton = { TextButton(onClick = { onPick(selected) }) { Text(stringResource(android.R.string.ok)) } },
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } },
)
}
@@ -0,0 +1,124 @@
package app.uhr.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
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.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import app.uhr.R
import app.uhr.data.Alarm
import app.uhr.data.Bedtime
import app.uhr.data.Settings
import app.uhr.ui.components.Fmt
import app.uhr.ui.components.SettingsChoice
import app.uhr.ui.components.SettingsGroup
import app.uhr.ui.components.SettingsSwitch
import app.uhr.ui.theme.WRadius
import app.uhr.ui.theme.WSpace
import app.uhr.ui.theme.WType
import app.uhr.ui.theme.tabular
/** S10: bedtime and wake-up, with the expected sleep; the wake-up becomes the "Aufwachen" alarm. */
@Composable
fun BedtimeScreen(b: Bedtime, settings: Settings, onSave: (Bedtime) -> Unit, contentPadding: PaddingValues) {
var pickBed by remember { mutableStateOf(false) }
var pickWake by remember { mutableStateOf(false) }
val scheme = MaterialTheme.colorScheme
if (!b.enabled && b == Bedtime()) {
Column(Modifier.fillMaxSize().padding(contentPadding).padding(WSpace.s32), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
Icon(Icons.Outlined.Bedtime, null, Modifier.size(72.dp), tint = scheme.primary)
Spacer(Modifier.height(WSpace.s16))
Text(stringResource(R.string.bedtime_intro_title), style = WType.headline, textAlign = TextAlign.Center)
Spacer(Modifier.height(WSpace.s8))
Text(stringResource(R.string.bedtime_intro), style = WType.body, color = scheme.onSurfaceVariant, textAlign = TextAlign.Center)
Spacer(Modifier.height(WSpace.s24))
Button(onClick = { onSave(b.copy(enabled = true)) }) { Text(stringResource(R.string.get_started)) }
}
return
}
val sleepMin = ((b.wakeHour * 60 + b.wakeMinute) - (b.bedHour * 60 + b.bedMinute) + 1440) % 1440
Column(Modifier.fillMaxSize().padding(contentPadding).verticalScroll(rememberScrollState())) {
Row(Modifier.fillMaxWidth().padding(horizontal = WSpace.s16, vertical = WSpace.s8), horizontalArrangement = Arrangement.spacedBy(WSpace.s8)) {
TimeTile(Icons.Outlined.Bedtime, stringResource(R.string.bedtime), Fmt.time(b.bedHour, b.bedMinute), Modifier.weight(1f)) { pickBed = true }
TimeTile(Icons.Outlined.WbSunny, stringResource(R.string.wake_up), Fmt.time(b.wakeHour, b.wakeMinute), Modifier.weight(1f)) { pickWake = true }
}
Text(
stringResource(R.string.sleep_duration, sleepMin / 60, sleepMin % 60), style = WType.body, color = scheme.onSurfaceVariant,
modifier = Modifier.fillMaxWidth().padding(WSpace.s8), textAlign = TextAlign.Center,
)
Row(Modifier.fillMaxWidth().padding(horizontal = WSpace.s24, vertical = WSpace.s8), horizontalArrangement = Arrangement.SpaceBetween) {
Fmt.orderedDays(settings.weekStart).forEach { d ->
val on = b.days and (1 shl (d.value - 1)) != 0
Box(
Modifier.size(38.dp).clip(CircleShape).background(if (on) scheme.primary else Color.Transparent)
.border(1.dp, if (on) scheme.primary else scheme.outline, CircleShape)
.clickable(role = Role.Checkbox) { onSave(b.copy(days = if (on) b.days and (1 shl (d.value - 1)).inv() else b.days or (1 shl (d.value - 1)))) }
.semantics { contentDescription = Fmt.dayFull(d) },
contentAlignment = Alignment.Center,
) { Text(Fmt.dayNarrow(d), style = WType.label, color = if (on) scheme.onPrimary else scheme.onSurface) }
}
}
SettingsGroup {
item { SettingsSwitch(it, stringResource(R.string.bedtime_schedule), b.enabled, { v -> onSave(b.copy(enabled = v)) }, icon = Icons.Outlined.Bedtime) }
item {
val options = listOf(-1, 0, 15, 30, 60)
SettingsChoice(it, stringResource(R.string.bedtime_reminder), options, b.reminderMin,
{ 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)
}
}
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 (pickWake) TimeDialog(b.wakeHour, b.wakeMinute, { pickWake = false }) { h, m -> pickWake = false; onSave(b.copy(wakeHour = h, wakeMinute = m)) }
}
@Composable
private fun TimeTile(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, time: String, modifier: Modifier, onClick: () -> Unit) {
Column(
modifier.clip(RoundedCornerShape(WRadius.card)).background(MaterialTheme.colorScheme.surfaceContainerLow).clickable(onClick = onClick).padding(WSpace.s20),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(icon, null, Modifier.size(20.dp), tint = MaterialTheme.colorScheme.primary)
Text(" $label", style = WType.label, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Spacer(Modifier.height(WSpace.s8))
Text(time, style = tabular(WType.timerTime))
}
}
@@ -0,0 +1,160 @@
package app.uhr.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Alarm
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import app.uhr.R
import app.uhr.data.AlarmInstance
import app.uhr.data.City
import app.uhr.data.ClockStyle
import app.uhr.data.Settings
import app.uhr.data.ZoneHit
import app.uhr.data.Zones
import app.uhr.ui.components.AnalogClock
import app.uhr.ui.components.Fmt
import app.uhr.ui.theme.WRadius
import app.uhr.ui.theme.WSpace
import app.uhr.ui.theme.WType
import app.uhr.ui.theme.tabular
import kotlinx.coroutines.delay
import java.time.Instant
import java.time.ZoneId
/** S06: local time and the world clock. [nowOverride] pins the time for screenshot tests. */
@Composable
fun ClockScreen(
settings: Settings,
cities: List<City>,
next: AlarmInstance?,
contentPadding: PaddingValues,
onDeleteCity: (City) -> Unit,
nowOverride: Long? = null,
) {
val tick = remember { mutableLongStateOf(nowOverride ?: System.currentTimeMillis()) }
if (nowOverride == null) LaunchedEffect(settings.showSeconds) {
while (true) { tick.longValue = System.currentTimeMillis(); delay(if (settings.showSeconds) 1000 else 1000 - tick.longValue % 1000) }
}
val now = tick.longValue
val res = LocalContext.current.resources
val scheme = MaterialTheme.colorScheme
LazyColumn(
Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = WSpace.s16, end = WSpace.s16, top = contentPadding.calculateTopPadding(), bottom = contentPadding.calculateBottomPadding() + 120.dp),
verticalArrangement = Arrangement.spacedBy(WSpace.s8),
) {
item(key = "header") {
Column(Modifier.fillMaxWidth().padding(vertical = WSpace.s24), horizontalAlignment = Alignment.CenterHorizontally) {
if (settings.clockStyle == ClockStyle.ANALOG) {
AnalogClock(
Instant.ofEpochMilli(now).atZone(ZoneId.systemDefault()), 240.dp,
face = scheme.surfaceContainerLow, hourHand = scheme.primary, minuteHand = scheme.onSurface,
secondHand = if (settings.showSeconds) scheme.tertiary else null,
)
} else {
Text(Fmt.clock(now, seconds = settings.showSeconds), style = tabular(WType.clockBig), color = scheme.onSurface)
}
Spacer(Modifier.height(WSpace.s8))
Text(Fmt.longDate(now), style = WType.body, color = scheme.onSurfaceVariant)
if (next != null) Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = WSpace.s4)) {
Icon(Icons.Outlined.Alarm, null, Modifier.size(16.dp), tint = scheme.onSurfaceVariant)
Text(" " + Fmt.ringDay(res, next.fireAt) + ", " + Fmt.clock(next.fireAt), style = WType.bodySm, color = scheme.onSurfaceVariant)
}
}
}
items(cities, key = { it.id }) { city -> CityRow(city, now, settings) { onDeleteCity(city) } }
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CityRow(city: City, now: Long, settings: Settings, onDelete: () -> Unit) {
val res = LocalContext.current.resources
val zone = remember(city.zoneId) { ZoneId.of(city.zoneId) }
val state = rememberSwipeToDismissBoxState()
LaunchedEffect(state.currentValue) { if (state.currentValue != SwipeToDismissBoxValue.Settled) onDelete() }
SwipeToDismissBox(
state,
backgroundContent = {
Box(Modifier.fillMaxSize().clip(RoundedCornerShape(WRadius.card)).background(MaterialTheme.colorScheme.errorContainer).padding(horizontal = 24.dp), contentAlignment = Alignment.CenterEnd) {
Icon(Icons.Rounded.Delete, stringResource(R.string.delete), tint = MaterialTheme.colorScheme.onErrorContainer)
}
},
) {
Row(
Modifier.fillMaxWidth().clip(RoundedCornerShape(WRadius.card)).background(MaterialTheme.colorScheme.surfaceContainerLow).padding(WSpace.s20),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(city.name, style = WType.title)
Text(Fmt.offset(res, zone, now), style = WType.bodySm, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Text(Fmt.clock(now, zone, settings.showSeconds), style = tabular(WType.timerTime))
}
}
}
/** S07: offline city search. */
@Composable
fun AddCityDialog(onDismiss: () -> Unit, onPick: (ZoneHit) -> Unit) {
var q by remember { mutableStateOf("") }
val hits = remember(q) { Zones.search(q) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.add_city)) },
text = {
Column {
OutlinedTextField(q, { q = it }, Modifier.fillMaxWidth(), placeholder = { Text(stringResource(R.string.search_city)) },
leadingIcon = { Icon(Icons.Rounded.Search, null) }, singleLine = true, shape = RoundedCornerShape(WRadius.pill))
LazyColumn(Modifier.height(360.dp).padding(top = WSpace.s8)) {
items(hits, key = { it.zoneId }) { h ->
Column(Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)).clickable { onPick(h) }.padding(vertical = 10.dp, horizontal = 4.dp)) {
Text(h.city, style = WType.body)
Text(h.zoneId.replace('_', ' '), style = WType.labelSm, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
},
confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } },
)
}
@@ -0,0 +1,92 @@
package app.uhr.ui.screens
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.outlined.AccessTime
import androidx.compose.material.icons.outlined.CalendarMonth
import androidx.compose.material.icons.outlined.DarkMode
import androidx.compose.material.icons.outlined.Snooze
import androidx.compose.material.icons.outlined.TrendingUp
import androidx.compose.material.icons.outlined.Vibration
import androidx.compose.material.icons.outlined.VolumeOff
import androidx.compose.material.icons.outlined.VolumeUp
import androidx.compose.material.icons.outlined.Watch
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LargeTopAppBar
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import app.uhr.R
import app.uhr.data.ClockStyle
import app.uhr.data.Settings
import app.uhr.data.ThemeMode
import app.uhr.data.VolumeButtons
import app.uhr.ui.components.Fmt
import app.uhr.ui.components.SettingsChoice
import app.uhr.ui.components.SettingsGroup
import app.uhr.ui.components.SettingsSwitch
import java.time.DayOfWeek
/** S13 */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(s: Settings, onChange: ((Settings) -> Settings) -> Unit, onBack: () -> Unit) {
val bar = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
val minutes = @Composable { m: Int -> if (m == 0) stringResource(R.string.never) else stringResource(R.string.n_minutes, m) }
Scaffold(
modifier = Modifier.nestedScroll(bar.nestedScrollConnection),
containerColor = MaterialTheme.colorScheme.surfaceContainer,
topBar = {
LargeTopAppBar(
title = { Text(stringResource(R.string.settings)) },
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Rounded.ArrowBack, stringResource(R.string.back)) } },
colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surfaceContainer, scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer),
scrollBehavior = bar,
)
},
) { pad ->
Column(Modifier.padding(pad).fillMaxSize().verticalScroll(rememberScrollState()).navigationBarsPadding()) {
SettingsGroup(stringResource(R.string.alarms)) {
item { SettingsChoice(it, stringResource(R.string.silence_after), listOf(1, 5, 10, 15, 20, 25, 0), s.silenceAfterMin, { m -> minutes(m) }, { v -> onChange { x -> x.copy(silenceAfterMin = v) } }, Icons.Outlined.VolumeOff) }
item { SettingsChoice(it, stringResource(R.string.snooze_length), (1..30).toList(), s.snoozeMin, { m -> stringResource(R.string.n_minutes, m) }, { v -> onChange { x -> x.copy(snoozeMin = v) } }, Icons.Outlined.Snooze) }
item { SettingsChoice(it, stringResource(R.string.alarm_volume), listOf(20, 40, 60, 80, 100), s.alarmVolume, { v -> "$v %" }, { v -> onChange { x -> x.copy(alarmVolume = v) } }, Icons.Outlined.VolumeUp) }
item { SettingsChoice(it, stringResource(R.string.gradual_volume), listOf(0, 5, 10, 15, 20, 30, 60), s.gradualVolumeS, { v -> if (v == 0) stringResource(R.string.off) else stringResource(R.string.n_seconds, v) }, { v -> onChange { x -> x.copy(gradualVolumeS = v) } }, Icons.Outlined.TrendingUp) }
item {
val labels = mapOf(VolumeButtons.VOLUME to R.string.vb_volume, VolumeButtons.SNOOZE to R.string.snooze, VolumeButtons.STOP to R.string.stop, VolumeButtons.NOTHING to R.string.vb_nothing)
SettingsChoice(it, stringResource(R.string.volume_buttons), VolumeButtons.entries, s.volumeButtons, { v -> stringResource(labels.getValue(v)) }, { v -> onChange { x -> x.copy(volumeButtons = v) } }, Icons.Outlined.VolumeUp)
}
item { SettingsChoice(it, stringResource(R.string.week_start), listOf(1, 6, 7), s.weekStart, { d -> Fmt.dayFull(DayOfWeek.of(d)) }, { v -> onChange { x -> x.copy(weekStart = v) } }, Icons.Outlined.CalendarMonth) }
}
SettingsGroup(stringResource(R.string.timer)) {
item { SettingsSwitch(it, stringResource(R.string.timer_vibrate), s.timerVibrate, { v -> onChange { x -> x.copy(timerVibrate = v) } }, icon = Icons.Outlined.Vibration) }
}
SettingsGroup(stringResource(R.string.clock)) {
item { SettingsChoice(it, stringResource(R.string.style), ClockStyle.entries, s.clockStyle, { v -> stringResource(if (v == ClockStyle.DIGITAL) R.string.digital else R.string.analog) }, { v -> onChange { x -> x.copy(clockStyle = v) } }, Icons.Outlined.Watch) }
item { SettingsSwitch(it, stringResource(R.string.show_seconds), s.showSeconds, { v -> onChange { x -> x.copy(showSeconds = v) } }, icon = Icons.Outlined.AccessTime) }
item {
val labels = mapOf(ThemeMode.SYSTEM to R.string.theme_system, ThemeMode.LIGHT to R.string.theme_light, ThemeMode.DARK to R.string.theme_dark)
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)
}
}
Spacer(Modifier.height(32.dp()))
}
}
}
private fun Int.dp() = androidx.compose.ui.unit.Dp(this.toFloat())
@@ -0,0 +1,74 @@
package app.uhr.ui.screens
import android.os.SystemClock
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.withFrameMillis
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import app.uhr.R
import app.uhr.data.Stopwatch
import app.uhr.data.StopwatchState
import app.uhr.ui.components.Fmt
import app.uhr.ui.theme.WSpace
import app.uhr.ui.theme.WType
import app.uhr.ui.theme.tabular
/** S09: big time, laps, three large pill buttons at the bottom. */
@Composable
fun StopwatchScreen(sw: Stopwatch, onToggle: () -> Unit, onLap: () -> Unit, onReset: () -> Unit, contentPadding: PaddingValues, nowOverride: Long? = null) {
val tick = remember { mutableLongStateOf(nowOverride ?: SystemClock.elapsedRealtime()) }
if (nowOverride == null && sw.state == StopwatchState.RUNNING) LaunchedEffect(sw) { while (true) withFrameMillis { tick.longValue = SystemClock.elapsedRealtime() } }
val scheme = MaterialTheme.colorScheme
val elapsed = sw.elapsed(tick.longValue)
Column(Modifier.fillMaxSize().padding(contentPadding).padding(horizontal = WSpace.s16)) {
Spacer(Modifier.height(WSpace.s32))
Text(Fmt.stopwatch(elapsed), style = tabular(WType.stopwatch), modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center)
val laps = sw.laps
LazyColumn(Modifier.weight(1f).padding(top = WSpace.s16)) {
val splits = laps.mapIndexed { i, total -> total - (laps.getOrNull(i - 1) ?: 0) }
val fastest = splits.minOrNull(); val slowest = splits.maxOrNull()
itemsIndexed(laps.reversed()) { ri, total ->
val i = laps.size - 1 - ri
val split = splits[i]
val color = when { laps.size < 2 -> scheme.onSurface; split == fastest -> scheme.primary; split == slowest -> scheme.error; else -> scheme.onSurface }
Row(Modifier.fillMaxWidth().padding(vertical = WSpace.s8, horizontal = WSpace.s8)) {
Text(stringResource(R.string.lap_n, i + 1), style = WType.body, color = color, modifier = Modifier.weight(1f))
Text(Fmt.stopwatch(split), style = tabular(WType.body), color = color, modifier = Modifier.weight(1f), textAlign = TextAlign.Center)
Text(Fmt.stopwatch(total), style = tabular(WType.body), color = scheme.onSurfaceVariant, modifier = Modifier.weight(1f), textAlign = TextAlign.End)
}
}
}
Column(verticalArrangement = Arrangement.spacedBy(WSpace.s8), modifier = Modifier.padding(bottom = WSpace.s16)) {
val running = sw.state == StopwatchState.RUNNING
Pill(
stringResource(if (running) R.string.stop else R.string.start),
if (running) scheme.tertiaryContainer else scheme.primaryContainer,
if (running) scheme.onTertiaryContainer else scheme.onPrimaryContainer,
height = 88, onClick = onToggle,
)
if (sw.state != StopwatchState.IDLE) {
Pill(stringResource(R.string.reset), scheme.surfaceContainerHigh, scheme.onSurface, height = 72, onClick = onReset)
if (running) Pill(stringResource(R.string.lap), scheme.surfaceContainerHigh, scheme.onSurface, height = 72, onClick = onLap)
}
}
}
}
@@ -0,0 +1,207 @@
package app.uhr.ui.screens
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.Backspace
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.Pause
import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.material.icons.rounded.Replay
import androidx.compose.material.icons.rounded.Stop
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import app.uhr.R
import app.uhr.data.Timer
import app.uhr.data.TimerState
import app.uhr.ui.components.Fmt
import app.uhr.ui.theme.WRadius
import app.uhr.ui.theme.WSpace
import app.uhr.ui.theme.WType
import app.uhr.ui.theme.tabular
import kotlinx.coroutines.delay
interface TimerActions {
fun start(durationMs: Long)
fun pause(id: Long)
fun resume(id: Long)
fun addMinute(id: Long)
fun reset(id: Long)
fun delete(id: Long)
fun stopRinging()
}
/** S08: keypad when there is no timer (or after "+"), cards for running ones. */
@Composable
fun TimerScreen(
timers: List<Timer>,
showKeypad: Boolean,
digits: String,
onDigits: (String) -> Unit,
actions: TimerActions,
contentPadding: PaddingValues,
nowOverride: Long? = null,
) {
if (timers.isEmpty() || showKeypad) {
Keypad(digits, onDigits, Modifier.padding(contentPadding))
return
}
val tick = remember { mutableLongStateOf(nowOverride ?: System.currentTimeMillis()) }
if (nowOverride == null) LaunchedEffect(Unit) { while (true) { tick.longValue = System.currentTimeMillis(); delay(100) } }
LazyColumn(
Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = WSpace.s16, end = WSpace.s16, top = contentPadding.calculateTopPadding(), bottom = contentPadding.calculateBottomPadding() + 120.dp),
verticalArrangement = Arrangement.spacedBy(WSpace.s8),
) {
items(timers, key = { it.id }) { TimerCard(it, tick.longValue, actions) }
}
}
@Composable
private fun TimerCard(t: Timer, now: Long, actions: TimerActions) {
val scheme = MaterialTheme.colorScheme
val remaining = t.remaining(now)
val progress = (remaining.toFloat() / t.durationMs).coerceIn(0f, 1f)
val blink = rememberInfiniteTransition(label = "blink").animateFloat(1f, .3f, infiniteRepeatable(tween(600), RepeatMode.Reverse), label = "a")
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))
IconButton(onClick = { actions.delete(t.id) }) { Icon(Icons.Rounded.Close, stringResource(R.string.delete)) }
}
Row(verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.weight(1f).aspectRatio(1f).padding(WSpace.s8), contentAlignment = Alignment.Center) {
val track = scheme.surfaceContainerHigh
val ring = scheme.primary
Canvas(Modifier.fillMaxSize()) {
val w = 6.dp.toPx()
drawArc(track, 0f, 360f, false, style = Stroke(w))
drawArc(ring, -90f, 360f * progress, false, style = Stroke(w, cap = StrokeCap.Round))
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
(if (remaining < 0) "−" else "") + Fmt.duration(remaining + if (remaining > 0) 999 else 0),
style = tabular(WType.timerTime),
modifier = Modifier.alpha(if (t.state == TimerState.PAUSED || t.state == TimerState.RINGING) blink.value else 1f)
.semantics { contentDescription = Fmt.duration(remaining) },
)
IconButton(onClick = { actions.reset(t.id) }) { Icon(Icons.Rounded.Replay, stringResource(R.string.reset)) }
}
}
Spacer(Modifier.width(WSpace.s16))
Column(Modifier.width(120.dp), verticalArrangement = Arrangement.spacedBy(WSpace.s12)) {
Pill(stringResource(R.string.plus_minute), scheme.surfaceContainerHigh, scheme.onSurface) { actions.addMinute(t.id) }
when (t.state) {
TimerState.RUNNING -> PillIcon(Icons.Rounded.Pause, stringResource(R.string.pause), app.uhr.ui.theme.fabColors().first, app.uhr.ui.theme.fabColors().second) { actions.pause(t.id) }
TimerState.PAUSED -> PillIcon(Icons.Rounded.PlayArrow, stringResource(R.string.start), app.uhr.ui.theme.fabColors().first, app.uhr.ui.theme.fabColors().second) { actions.resume(t.id) }
TimerState.RINGING -> PillIcon(Icons.Rounded.Stop, stringResource(R.string.stop), scheme.tertiaryContainer, scheme.onTertiaryContainer) { actions.stopRinging() }
}
}
}
}
}
@Composable
fun Pill(text: String, bg: androidx.compose.ui.graphics.Color, fg: androidx.compose.ui.graphics.Color, modifier: Modifier = Modifier, height: Int = 56, onClick: () -> Unit) {
Box(modifier.fillMaxWidth().height(height.dp).clip(RoundedCornerShape(WRadius.pill)).background(bg).clickable(onClick = onClick), contentAlignment = Alignment.Center) {
Text(text, style = if (height >= 72) WType.title else WType.label, color = fg)
}
}
@Composable
fun PillIcon(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, bg: androidx.compose.ui.graphics.Color, fg: androidx.compose.ui.graphics.Color, onClick: () -> Unit) {
Box(Modifier.fillMaxWidth().height(64.dp).clip(RoundedCornerShape(WRadius.pill)).background(bg).clickable(onClick = onClick), contentAlignment = Alignment.Center) {
Icon(icon, label, tint = fg)
}
}
/** Digits fill from the right: "130" = 1 min 30 s. Max 6 digits (99h 59m 59s). */
fun digitsToMs(d: String): Long {
val p = d.padStart(6, '0')
return ((p.substring(0, 2).toLong() * 3600) + p.substring(2, 4).toLong() * 60 + p.substring(4, 6).toLong()) * 1000
}
@Composable
private fun Keypad(digits: String, onDigits: (String) -> Unit, modifier: Modifier) {
val scheme = MaterialTheme.colorScheme
val p = digits.padStart(6, '0')
Column(modifier.fillMaxSize().padding(horizontal = WSpace.s24), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
val active = scheme.primary; val idle = scheme.onSurfaceVariant
Text(
buildAnnotatedString {
listOf(p.substring(0, 2) to "h", p.substring(2, 4) to "m", p.substring(4, 6) to "s").forEachIndexed { i, (v, u) ->
val c = if (digits.length > 4 - i * 2) active else idle
withStyle(SpanStyle(color = c, fontSize = WType.clockBig.fontSize * .7f)) { append(v) }
withStyle(SpanStyle(color = c, fontSize = WType.title.fontSize)) { append(u + " ") }
}
},
style = tabular(WType.clockBig),
)
Spacer(Modifier.height(WSpace.s32))
val keys = listOf("1", "2", "3", "4", "5", "6", "7", "8", "9", "00", "0", "⌫")
keys.chunked(3).forEach { row ->
Row(horizontalArrangement = Arrangement.spacedBy(WSpace.s16), modifier = Modifier.padding(vertical = WSpace.s8)) {
row.forEach { k ->
Box(
Modifier.size(80.dp).clip(CircleShape).background(if (k == "⌫") scheme.secondaryContainer else scheme.surfaceContainerHigh)
.clickable {
onDigits(
when (k) {
"⌫" -> digits.dropLast(1)
else -> (digits + k).trimStart('0').take(6)
}
)
},
contentAlignment = Alignment.Center,
) {
if (k == "⌫") Icon(Icons.AutoMirrored.Rounded.Backspace, stringResource(R.string.backspace))
else Text(k, style = WType.headline)
}
}
}
}
}
}
@@ -0,0 +1,72 @@
package app.uhr.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import app.uhr.data.ThemeMode
private val Light = lightColorScheme(
primary = WColor.accent, onPrimary = WColor.onAccent,
primaryContainer = WColor.accentContainer, onPrimaryContainer = WColor.onAccentContainer,
secondaryContainer = WColor.navIndicator, onSecondaryContainer = WColor.text,
tertiaryContainer = WColor.stop, onTertiaryContainer = WColor.onStop,
background = WColor.bg, onBackground = WColor.text,
surface = WColor.bg, onSurface = WColor.text, onSurfaceVariant = WColor.textMuted,
surfaceContainer = WColor.nav, surfaceContainerLow = WColor.card, surfaceContainerHigh = WColor.cardHigh,
surfaceContainerLowest = WColor.surface,
outline = WColor.borderInput, outlineVariant = WColor.border, error = WColor.danger,
)
private val Dark = darkColorScheme(
primary = WColor.darkAccent, onPrimary = WColor.darkOnAccent,
primaryContainer = WColor.darkAccentContainer, onPrimaryContainer = WColor.darkOnAccentContainer,
secondaryContainer = WColor.darkNavIndicator, onSecondaryContainer = WColor.darkText,
tertiaryContainer = WColor.darkStop, onTertiaryContainer = WColor.darkOnStop,
background = WColor.darkBg, onBackground = WColor.darkText,
surface = WColor.darkBg, onSurface = WColor.darkText, onSurfaceVariant = WColor.darkTextMuted,
surfaceContainer = WColor.darkNav, surfaceContainerLow = WColor.darkCard, surfaceContainerHigh = WColor.darkCardHigh,
surfaceContainerLowest = WColor.darkCardHigh,
outline = WColor.darkBorderInput, outlineVariant = WColor.darkBorder, error = WColor.darkDanger,
)
private val AppTypography = Typography(
displayLarge = WType.clockBig, headlineLarge = WType.headline, titleLarge = WType.title,
bodyLarge = WType.body, bodyMedium = WType.bodySm, labelLarge = WType.label, labelMedium = WType.label, labelSmall = WType.labelSm,
)
/** Tabular figures so times do not jump while they tick. */
fun tabular(style: androidx.compose.ui.text.TextStyle) = style.copy(fontFeatureSettings = "tnum")
@Composable
fun ClockTheme(mode: ThemeMode = ThemeMode.SYSTEM, content: @Composable () -> Unit) {
val dark = when (mode) { ThemeMode.SYSTEM -> isSystemInDarkTheme(); ThemeMode.LIGHT -> false; ThemeMode.DARK -> true }
val context = LocalContext.current
// wallpaper colours on Android 12+, like the original; our palette below that
val scheme = when {
Build.VERSION.SDK_INT >= 31 -> if (dark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
dark -> Dark
else -> Light
}
MaterialTheme(colorScheme = scheme, typography = AppTypography, content = content)
}
/** Fixed palette, for screenshot tests that must not depend on the wallpaper. */
@Composable
fun StaticClockTheme(dark: Boolean, content: @Composable () -> Unit) =
MaterialTheme(colorScheme = if (dark) Dark else Light, typography = AppTypography, content = content)
/** The big FAB is light with a dark icon in dark mode, tonal in light mode (as in the reference). */
@Composable
fun fabColors(): Pair<androidx.compose.ui.graphics.Color, androidx.compose.ui.graphics.Color> {
val s = MaterialTheme.colorScheme
val dark = s.background.run { 0.2126f * red + 0.7152f * green + 0.0722f * blue } < .5f
return if (dark) s.primary to s.onPrimary else s.primaryContainer to s.onPrimaryContainer
}
@@ -0,0 +1,106 @@
// Generated from tokens.json by gen_theme.py. Do not edit by hand.
package app.uhr.ui.theme
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
object WColor {
val bg = Color(0xFFF8F9FF)
val surface = Color(0xFFFFFFFF)
val card = Color(0xFFEEF0F6)
val cardHigh = Color(0xFFE2E5EC)
val border = Color(0xFFC3C6CF)
val borderInput = Color(0xFF73777F)
val text = Color(0xFF191C20)
val textMuted = Color(0xFF43474E)
val textOff = Color(0xFF5C6068)
val accent = Color(0xFF2F5EA8)
val onAccent = Color(0xFFFFFFFF)
val accentContainer = Color(0xFFD6E3FF)
val onAccentContainer = Color(0xFF001B3E)
val fab = Color(0xFFA8C7FA)
val onFab = Color(0xFF0B2A55)
val chipOn = Color(0xFF2F5EA8)
val onChipOn = Color(0xFFFFFFFF)
val secondaryPill = Color(0xFFDFE2EB)
val onSecondaryPill = Color(0xFF191C20)
val stop = Color(0xFFFFD8E4)
val onStop = Color(0xFF4A1426)
val danger = Color(0xFFBA1A1A)
val nav = Color(0xFFECEEF4)
val navIndicator = Color(0xFFD6E3FF)
val darkBg = Color(0xFF111318)
val darkSurface = Color(0xFF111318)
val darkCard = Color(0xFF1D2024)
val darkCardHigh = Color(0xFF272A2F)
val darkBorder = Color(0xFF43474E)
val darkBorderInput = Color(0xFF8D9199)
val darkText = Color(0xFFE2E2E9)
val darkTextMuted = Color(0xFFC3C6CF)
val darkTextOff = Color(0xFF9A9EA6)
val darkAccent = Color(0xFFA8C7FA)
val darkOnAccent = Color(0xFF062E6F)
val darkAccentContainer = Color(0xFF244A80)
val darkOnAccentContainer = Color(0xFFD6E3FF)
val darkFab = Color(0xFFA8C7FA)
val darkOnFab = Color(0xFF062E6F)
val darkChipOn = Color(0xFFA8C7FA)
val darkOnChipOn = Color(0xFF062E6F)
val darkSecondaryPill = Color(0xFF3A3D43)
val darkOnSecondaryPill = Color(0xFFE2E2E9)
val darkStop = Color(0xFFF2B8C9)
val darkOnStop = Color(0xFF4A1426)
val darkDanger = Color(0xFFFFB4AB)
val darkNav = Color(0xFF1D2024)
val darkNavIndicator = Color(0xFF3A3F47)
val ringBg = Color(0xFF1E5A52)
val ringOn = Color(0xFFB4F1E6)
val ringTrack = Color(0xFF2B6E65)
val ringThumb = Color(0xFF3F9A7F)
val onRingThumb = Color(0xFFFFFFFF)
val timerTrack = Color(0xFF3A3D43)
val timerProgress = Color(0xFF7EC4A8)
val nightText = Color(0xFFC4433A)
val nightBg = Color(0xFF000000)
}
object WType {
val labelSm = TextStyle(fontSize = 12.sp, lineHeight = 16.sp, fontWeight = FontWeight.W500)
val label = TextStyle(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.W500)
val bodySm = TextStyle(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.W400)
val body = TextStyle(fontSize = 16.sp, lineHeight = 24.sp, fontWeight = FontWeight.W400)
val title = TextStyle(fontSize = 22.sp, lineHeight = 28.sp, fontWeight = FontWeight.W400)
val headline = TextStyle(fontSize = 28.sp, lineHeight = 36.sp, fontWeight = FontWeight.W400)
val alarmTime = TextStyle(fontSize = 56.sp, lineHeight = 60.sp, fontWeight = FontWeight.W600)
val alarmAmpm = TextStyle(fontSize = 20.sp, lineHeight = 24.sp, fontWeight = FontWeight.W600)
val timerTime = TextStyle(fontSize = 44.sp, lineHeight = 48.sp, fontWeight = FontWeight.W400)
val clockBig = TextStyle(fontSize = 88.sp, lineHeight = 92.sp, fontWeight = FontWeight.W400)
val stopwatch = TextStyle(fontSize = 72.sp, lineHeight = 76.sp, fontWeight = FontWeight.W500)
val ringing = TextStyle(fontSize = 104.sp, lineHeight = 108.sp, fontWeight = FontWeight.W400)
}
object WSpace {
val s0 = 0.dp
val s2 = 2.dp
val s4 = 4.dp
val s8 = 8.dp
val s12 = 12.dp
val s16 = 16.dp
val s20 = 20.dp
val s24 = 24.dp
val s32 = 32.dp
val s48 = 48.dp
}
object WRadius {
val xs = 8.dp
val sm = 12.dp
val md = 16.dp
val lg = 24.dp
val card = 28.dp
val pill = 999.dp
val navIndicator = 16.dp
}
@@ -0,0 +1,149 @@
package app.uhr.widget
import android.content.Context
import android.content.res.Configuration
import android.os.Build
import android.util.TypedValue
import android.widget.RemoteViews
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.unit.dp
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.LocalContext
import androidx.glance.LocalSize
import androidx.glance.action.clickable
import androidx.glance.appwidget.AndroidRemoteViews
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import androidx.glance.appwidget.SizeMode
import androidx.glance.appwidget.action.actionStartActivity
import androidx.glance.appwidget.cornerRadius
import androidx.glance.appwidget.provideContent
import androidx.glance.appwidget.state.updateAppWidgetState
import androidx.glance.background
import androidx.glance.currentState
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.fillMaxSize
import app.uhr.MainActivity
import app.uhr.R
import app.uhr.data.Graph
import app.uhr.ui.components.Fmt
import app.uhr.ui.theme.WColor
private val VERSION = longPreferencesKey("v")
/** Widget colours: wallpaper on Android 12+, our tokens below. */
private data class WPal(val bg: Color, val fg: Color, val accent: Color)
private fun palette(context: Context): WPal {
val dark = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
if (Build.VERSION.SDK_INT >= 31) {
val s = if (dark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
return WPal(s.primaryContainer, s.onPrimaryContainer, s.primary)
}
return if (dark) WPal(WColor.darkCard, WColor.darkText, WColor.darkAccent) else WPal(WColor.card, WColor.text, WColor.accent)
}
/** Glance's update() only recomposes; bumping state makes widgets read the next alarm again. */
suspend fun updateClockWidgets(context: Context) {
val manager = GlanceAppWidgetManager(context)
for (w in listOf(DigitalClockWidget(), AnalogClockWidget(), NextAlarmWidget())) {
manager.getGlanceIds(w.javaClass).forEach { id ->
runCatching { updateAppWidgetState(context, id) { it[VERSION] = System.nanoTime() }; w.update(context, id) }
}
}
}
private suspend fun nextAlarmText(context: Context): String? {
Graph.init(context)
val i = Graph.repo.nextInstanceOnce() ?: return null
return Fmt.ringDay(context.resources, i.fireAt) + ", " + Fmt.clock(i.fireAt)
}
abstract class ClockWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
Fmt.is24h = android.text.format.DateFormat.is24HourFormat(context)
val first = nextAlarmText(context)
provideContent {
val v = currentState(VERSION) ?: 0L
val next by produceState(first, v) { if (v != 0L) value = nextAlarmText(context) }
Content(next)
}
}
override suspend fun providePreview(context: Context, widgetCategory: Int) {
val next = nextAlarmText(context)
provideContent { Content(next) }
}
@Composable abstract fun Content(next: String?)
}
private fun RemoteViews.color(id: Int, c: Color) = setTextColor(id, c.toArgb())
/** Big live time, date, next alarm. Transparent, like a home-screen clock. */
class DigitalClockWidget : ClockWidget() {
override val sizeMode = SizeMode.Exact
@Composable
override fun Content(next: String?) {
val ctx = LocalContext.current
val size = LocalSize.current
val views = RemoteViews(ctx.packageName, R.layout.widget_digital).apply {
setTextViewTextSize(R.id.w_time, TypedValue.COMPLEX_UNIT_SP, (size.height.value * .45f).coerceIn(40f, 110f))
if (next != null) setTextViewText(R.id.w_next, "⏰ $next") else setTextViewText(R.id.w_next, "")
}
Box(GlanceModifier.fillMaxSize().clickable(actionStartActivity(android.content.Intent(ctx, MainActivity::class.java))), contentAlignment = Alignment.Center) {
AndroidRemoteViews(views)
}
}
}
/** Own analog face on a wallpaper-coloured disc. */
class AnalogClockWidget : ClockWidget() {
override val sizeMode = SizeMode.Exact
@Composable
override fun Content(next: String?) {
val ctx = LocalContext.current
val p = palette(ctx)
val views = RemoteViews(ctx.packageName, R.layout.widget_analog)
Box(
GlanceModifier.fillMaxSize().cornerRadius(999.dp).background(p.bg).clickable(actionStartActivity(android.content.Intent(ctx, MainActivity::class.java))),
contentAlignment = Alignment.Center,
) { AndroidRemoteViews(views) }
}
}
/** Pill with the next alarm. */
class NextAlarmWidget : ClockWidget() {
override val sizeMode = SizeMode.Exact
@Composable
override fun Content(next: String?) {
val ctx = LocalContext.current
val p = palette(ctx)
val views = RemoteViews(ctx.packageName, R.layout.widget_next).apply {
setTextViewText(R.id.w_next_text, next ?: ctx.getString(R.string.no_alarm_set))
color(R.id.w_next_text, p.fg)
setInt(R.id.w_next_icon, "setColorFilter", p.accent.toArgb())
}
Box(
GlanceModifier.fillMaxSize().cornerRadius(999.dp).background(p.bg).clickable(actionStartActivity(android.content.Intent(ctx, MainActivity::class.java))),
contentAlignment = Alignment.Center,
) { AndroidRemoteViews(views) }
}
}
class DigitalClockReceiver : GlanceAppWidgetReceiver() { override val glanceAppWidget: GlanceAppWidget = DigitalClockWidget() }
class AnalogClockReceiver : GlanceAppWidgetReceiver() { override val glanceAppWidget: GlanceAppWidget = AnalogClockWidget() }
class NextAlarmReceiver : GlanceAppWidgetReceiver() { override val glanceAppWidget: GlanceAppWidget = NextAlarmWidget() }
@@ -0,0 +1,6 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="200dp" android:height="200dp" android:viewportWidth="200" android:viewportHeight="200">
<path android:fillColor="#40FFFFFF" android:pathData="M100,14m-5,0a5,5 0 1,0 10,0a5,5 0 1,0 -10,0"/>
<path android:fillColor="#40FFFFFF" android:pathData="M186,100m-5,0a5,5 0 1,0 10,0a5,5 0 1,0 -10,0"/>
<path android:fillColor="#40FFFFFF" android:pathData="M100,186m-5,0a5,5 0 1,0 10,0a5,5 0 1,0 -10,0"/>
<path android:fillColor="#40FFFFFF" android:pathData="M14,100m-5,0a5,5 0 1,0 10,0a5,5 0 1,0 -10,0"/>
</vector>
@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="200dp" android:height="200dp" android:viewportWidth="200" android:viewportHeight="200">
<path android:strokeColor="#FFA8C7FA" android:strokeWidth="14" android:strokeLineCap="round" android:pathData="M100,100 L100,52"/>
</vector>
@@ -0,0 +1,4 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="200dp" android:height="200dp" android:viewportWidth="200" android:viewportHeight="200">
<path android:strokeColor="#FFFFFFFF" android:strokeWidth="8" android:strokeLineCap="round" android:pathData="M100,100 L100,30"/>
<path android:fillColor="#FFFFFFFF" android:pathData="M100,100m-9,0a9,9 0 1,0 18,0a9,9 0 1,0 -18,0"/>
</vector>
@@ -0,0 +1 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="108dp" android:height="108dp" android:viewportWidth="108" android:viewportHeight="108"><path android:fillColor="#FF2F5EA8" android:pathData="M0,0h108v108h-108z"/></vector>
@@ -0,0 +1,6 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="108dp" android:height="108dp" android:viewportWidth="108" android:viewportHeight="108">
<path android:fillColor="#FFD6E3FF" android:pathData="M82.22,54.00 L82.03,55.22 L81.50,56.41 L80.77,57.52 L79.99,58.58 L79.33,59.62 L78.91,60.67 L78.75,61.80 L78.80,63.03 L78.94,64.33 L79.02,65.67 L78.88,66.95 L78.43,68.11 L77.66,69.07 L76.61,69.84 L75.42,70.44 L74.22,70.96 L73.13,71.53 L72.23,72.23 L71.53,73.13 L70.96,74.22 L70.44,75.42 L69.84,76.61 L69.07,77.66 L68.11,78.43 L66.95,78.88 L65.67,79.02 L64.33,78.94 L63.03,78.80 L61.80,78.75 L60.67,78.91 L59.62,79.33 L58.58,79.99 L57.52,80.77 L56.41,81.50 L55.22,82.03 L54.00,82.22 L52.78,82.03 L51.59,81.50 L50.48,80.77 L49.42,79.99 L48.38,79.33 L47.33,78.91 L46.20,78.75 L44.97,78.80 L43.67,78.94 L42.33,79.02 L41.05,78.88 L39.89,78.43 L38.93,77.66 L38.16,76.61 L37.56,75.42 L37.04,74.22 L36.47,73.13 L35.77,72.23 L34.87,71.53 L33.78,70.96 L32.58,70.44 L31.39,69.84 L30.34,69.07 L29.57,68.11 L29.12,66.95 L28.98,65.67 L29.06,64.33 L29.20,63.03 L29.25,61.80 L29.09,60.67 L28.67,59.62 L28.01,58.58 L27.23,57.52 L26.50,56.41 L25.97,55.22 L25.79,54.00 L25.97,52.78 L26.50,51.59 L27.23,50.48 L28.01,49.42 L28.67,48.38 L29.09,47.33 L29.25,46.20 L29.20,44.97 L29.06,43.67 L28.98,42.33 L29.12,41.05 L29.57,39.89 L30.34,38.93 L31.39,38.16 L32.58,37.56 L33.78,37.04 L34.87,36.47 L35.77,35.77 L36.47,34.87 L37.04,33.78 L37.56,32.58 L38.16,31.39 L38.93,30.34 L39.89,29.57 L41.05,29.12 L42.33,28.98 L43.67,29.06 L44.97,29.20 L46.20,29.25 L47.33,29.09 L48.38,28.67 L49.42,28.01 L50.48,27.23 L51.59,26.50 L52.78,25.97 L54.00,25.79 L55.22,25.97 L56.41,26.50 L57.52,27.23 L58.58,28.01 L59.62,28.67 L60.67,29.09 L61.80,29.25 L63.03,29.20 L64.33,29.06 L65.67,28.98 L66.95,29.12 L68.11,29.57 L69.07,30.34 L69.84,31.39 L70.44,32.58 L70.96,33.78 L71.53,34.87 L72.23,35.77 L73.13,36.47 L74.22,37.04 L75.42,37.56 L76.61,38.16 L77.66,38.93 L78.43,39.89 L78.88,41.05 L79.02,42.33 L78.94,43.67 L78.80,44.97 L78.75,46.20 L78.91,47.33 L79.33,48.38 L79.99,49.42 L80.77,50.48 L81.50,51.59 L82.03,52.78 Z"/>
<path android:strokeColor="#FF0B2A55" android:strokeWidth="5.5" android:strokeLineCap="round" android:pathData="M54,54 L54,38"/>
<path android:strokeColor="#FF2F5EA8" android:strokeWidth="3.5" android:strokeLineCap="round" android:pathData="M54,54 L66,64"/>
<path android:fillColor="#FF0B2A55" android:pathData="M54,54m-3.5,0a3.5,3.5 0 1,0 7,0a3.5,3.5 0 1,0 -7,0"/>
</vector>
@@ -0,0 +1,6 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="108dp" android:height="108dp" android:viewportWidth="108" android:viewportHeight="108">
<path android:fillColor="#FF000000" android:pathData="M82.22,54.00 L82.03,55.22 L81.50,56.41 L80.77,57.52 L79.99,58.58 L79.33,59.62 L78.91,60.67 L78.75,61.80 L78.80,63.03 L78.94,64.33 L79.02,65.67 L78.88,66.95 L78.43,68.11 L77.66,69.07 L76.61,69.84 L75.42,70.44 L74.22,70.96 L73.13,71.53 L72.23,72.23 L71.53,73.13 L70.96,74.22 L70.44,75.42 L69.84,76.61 L69.07,77.66 L68.11,78.43 L66.95,78.88 L65.67,79.02 L64.33,78.94 L63.03,78.80 L61.80,78.75 L60.67,78.91 L59.62,79.33 L58.58,79.99 L57.52,80.77 L56.41,81.50 L55.22,82.03 L54.00,82.22 L52.78,82.03 L51.59,81.50 L50.48,80.77 L49.42,79.99 L48.38,79.33 L47.33,78.91 L46.20,78.75 L44.97,78.80 L43.67,78.94 L42.33,79.02 L41.05,78.88 L39.89,78.43 L38.93,77.66 L38.16,76.61 L37.56,75.42 L37.04,74.22 L36.47,73.13 L35.77,72.23 L34.87,71.53 L33.78,70.96 L32.58,70.44 L31.39,69.84 L30.34,69.07 L29.57,68.11 L29.12,66.95 L28.98,65.67 L29.06,64.33 L29.20,63.03 L29.25,61.80 L29.09,60.67 L28.67,59.62 L28.01,58.58 L27.23,57.52 L26.50,56.41 L25.97,55.22 L25.79,54.00 L25.97,52.78 L26.50,51.59 L27.23,50.48 L28.01,49.42 L28.67,48.38 L29.09,47.33 L29.25,46.20 L29.20,44.97 L29.06,43.67 L28.98,42.33 L29.12,41.05 L29.57,39.89 L30.34,38.93 L31.39,38.16 L32.58,37.56 L33.78,37.04 L34.87,36.47 L35.77,35.77 L36.47,34.87 L37.04,33.78 L37.56,32.58 L38.16,31.39 L38.93,30.34 L39.89,29.57 L41.05,29.12 L42.33,28.98 L43.67,29.06 L44.97,29.20 L46.20,29.25 L47.33,29.09 L48.38,28.67 L49.42,28.01 L50.48,27.23 L51.59,26.50 L52.78,25.97 L54.00,25.79 L55.22,25.97 L56.41,26.50 L57.52,27.23 L58.58,28.01 L59.62,28.67 L60.67,29.09 L61.80,29.25 L63.03,29.20 L64.33,29.06 L65.67,28.98 L66.95,29.12 L68.11,29.57 L69.07,30.34 L69.84,31.39 L70.44,32.58 L70.96,33.78 L71.53,34.87 L72.23,35.77 L73.13,36.47 L74.22,37.04 L75.42,37.56 L76.61,38.16 L77.66,38.93 L78.43,39.89 L78.88,41.05 L79.02,42.33 L78.94,43.67 L78.80,44.97 L78.75,46.20 L78.91,47.33 L79.33,48.38 L79.99,49.42 L80.77,50.48 L81.50,51.59 L82.03,52.78 Z"/>
<path android:strokeColor="#FFFFFFFF" android:strokeWidth="5.5" android:strokeLineCap="round" android:pathData="M54,54 L54,38"/>
<path android:strokeColor="#FFFFFFFF" android:strokeWidth="3.5" android:strokeLineCap="round" android:pathData="M54,54 L66,64"/>
<path android:fillColor="#FFFFFFFF" android:pathData="M54,54m-3.5,0a3.5,3.5 0 1,0 7,0a3.5,3.5 0 1,0 -7,0"/>
</vector>
@@ -0,0 +1 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#FFFFFFFF" android:pathData="M12,4a8,8 0 1,0 0,16a8,8 0 1,0 0,-16zM12,18a6,6 0 1,1 0,-12a6,6 0 1,1 0,12zM12.5,8H11v5l3.9,2.4 0.8,-1.2 -3.2,-1.9zM6.2,2.3L2.3,5.6 3.6,7.1 7.4,3.9zM17.8,2.3l-1.3,1.5 3.8,3.2 1.3,-1.5z"/></vector>
@@ -0,0 +1 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#FFFFFFFF" android:pathData="M12.3,22h-0.1a10.3,10.3 0 0,1 -7.3,-3A10.3,10.3 0 0,1 9.1,1.8a1,1 0 0,1 1.3,1.2 8.2,8.2 0 0,0 10.6,10.6 1,1 0,0 1 1.2,1.3A10.3,10.3 0 0,1 12.3,22z"/></vector>
@@ -0,0 +1 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#FFFFFFFF" android:pathData="M6,2v6h0.01L6,8.01 10,12l-4,4 0.01,0.01H6V22h12v-5.99h-0.01L18,16l-4,-4 4,-3.99 -0.01,-0.01H18V2H6zM16,16.5V20H8v-3.5l4,-4 4,4zM12,11.5l-4,-4V4h8v3.5l-4,4z"/></vector>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<AnalogClock xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent" android:layout_height="match_parent" android:padding="8dp"
android:dial="@drawable/analog_dial" android:hand_hour="@drawable/analog_hour" android:hand_minute="@drawable/analog_minute" />
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:gravity="center">
<TextClock android:id="@+id/w_time" android:layout_width="wrap_content" android:layout_height="wrap_content"
android:format24Hour="HH:mm" android:format12Hour="h:mm" android:fontFamily="sans-serif-light"
android:textSize="80sp" android:includeFontPadding="false" android:textColor="#FFFFFFFF" android:shadowColor="#66000000" android:shadowRadius="8" android:shadowDy="2" />
<TextClock android:layout_width="wrap_content" android:layout_height="wrap_content"
android:format24Hour="EEEE, d. MMMM" android:format12Hour="EEEE, MMMM d" android:fontFamily="sans-serif-medium"
android:textSize="16sp" android:textColor="#FFFFFFFF" android:shadowColor="#66000000" android:shadowRadius="8" android:shadowDy="2" />
<TextView android:id="@+id/w_next" android:layout_width="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="2dp" android:textSize="14sp" android:textColor="#E6FFFFFF" android:shadowColor="#66000000" android:shadowRadius="8" android:shadowDy="2" />
</LinearLayout>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="horizontal"
android:gravity="center" android:paddingHorizontal="20dp">
<ImageView android:id="@+id/w_next_icon" android:layout_width="24dp" android:layout_height="24dp" android:src="@drawable/ic_stat_alarm" android:importantForAccessibility="no" />
<TextView android:id="@+id/w_next_text" android:layout_width="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="10dp" android:fontFamily="sans-serif-medium" android:textSize="16sp" android:maxLines="1" android:ellipsize="end" />
</LinearLayout>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_monochrome"/>
</adaptive-icon>
Binary file not shown.
+130
View File
@@ -0,0 +1,130 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="tab_alarm">Wecker</string>
<string name="tab_clock">Uhr</string>
<string name="tab_timer">Timer</string>
<string name="tab_stopwatch">Stoppuhr</string>
<string name="tab_bedtime">Schlafen</string>
<string name="more">Weitere Optionen</string>
<string name="settings">Einstellungen</string>
<string name="back">Zurück</string>
<string name="add">Hinzufügen</string>
<string name="delete">Löschen</string>
<string name="undo">Rückgängig</string>
<string name="on">An</string>
<string name="off">Aus</string>
<string name="never">Nie</string>
<string name="later">Später</string>
<string name="open_settings">Einstellungen öffnen</string>
<string name="alarm">Wecker</string>
<string name="alarms">Wecker</string>
<string name="no_alarms">Noch keine Wecker. Tippe auf +, um einen zu stellen.</string>
<string name="wake_up">Aufwachen</string>
<string name="other_alarms">Weitere</string>
<string name="alarm_deleted">Wecker gelöscht</string>
<string name="alarm_set_in">Klingelt %1$s</string>
<string name="in_days_hours">in %1$d T. %2$d Std.</string>
<string name="in_hours_minutes">in %1$d Std. %2$d Min.</string>
<string name="in_minutes">in %1$d Min.</string>
<string name="today">Heute</string>
<string name="tomorrow">Morgen</string>
<string name="yesterday">Gestern</string>
<string name="every_day">Jeden Tag</string>
<string name="weekdays">Werktags</string>
<string name="weekends">Am Wochenende</string>
<string name="not_scheduled">Nicht geplant</string>
<string name="snoozed_until">Schlummert bis %1$s</string>
<string name="paused_until">Pausiert · nächstes Mal %1$s</string>
<string name="paused_until_date">Pausiert bis %1$s</string>
<string name="pause_alarm">Wecker pausieren</string>
<string name="pause_until">Pausieren bis einschließlich</string>
<string name="resume">Nicht pausieren</string>
<string name="skip_next">Nächsten Weckruf überspringen</string>
<string name="add_label">Name hinzufügen</string>
<string name="vibrate">Vibration</string>
<string name="alarm_sound">Weckton</string>
<string name="sound_default">Standard (Morgenklang)</string>
<string name="sound_silent">Ohne Ton</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>
<string name="use_keyboard">Tastatur</string>
<string name="snooze">Schlummern</string>
<string name="stop">Stopp</string>
<string name="dismiss_now">Jetzt beenden</string>
<string name="clock">Uhr</string>
<string name="add_city">Stadt hinzufügen</string>
<string name="search_city">Stadt oder Zeitzone</string>
<string name="same_time">Gleiche Zeit</string>
<string name="hours_offset">%1$s Std.</string>
<string name="timer">Timer</string>
<string name="timer_of">Timer · %1$s</string>
<string name="timer_done">Timer abgelaufen</string>
<string name="plus_minute">+1:00</string>
<string name="pause">Pause</string>
<string name="paused">Pausiert</string>
<string name="start">Start</string>
<string name="reset">Zurücksetzen</string>
<string name="backspace">Löschen</string>
<string name="n_timers">%1$d Timer</string>
<string name="stopwatch">Stoppuhr</string>
<string name="lap">Runde</string>
<string name="lap_n">Runde %1$d</string>
<string name="bedtime">Schlafenszeit</string>
<string name="bedtime_intro_title">Ein fester Schlafrhythmus</string>
<string name="bedtime_intro">Lege fest, wann du schlafen gehst und aufstehst. Du wirst rechtzeitig erinnert, und der Wecker zum Aufwachen wird automatisch gestellt.</string>
<string name="get_started">Los geht\'s</string>
<string name="sleep_duration">%1$d Std. %2$d Min. Schlaf</string>
<string name="bedtime_schedule">Zeitplan aktiv</string>
<string name="bedtime_reminder">Erinnerung</string>
<string name="at_bedtime">Zur Schlafenszeit</string>
<string name="minutes_before">%1$d Min. vorher</string>
<string name="silence_after">Automatisch stumm nach</string>
<string name="snooze_length">Schlummerdauer</string>
<string name="alarm_volume">Lautstärke</string>
<string name="gradual_volume">Langsam lauter werden</string>
<string name="volume_buttons">Lautstärketasten beim Klingeln</string>
<string name="vb_volume">Lautstärke ändern</string>
<string name="vb_nothing">Nichts tun</string>
<string name="week_start">Woche beginnt am</string>
<string name="timer_vibrate">Timer vibriert</string>
<string name="style">Darstellung</string>
<string name="digital">Digital</string>
<string name="analog">Analog</string>
<string name="show_seconds">Sekunden anzeigen</string>
<string name="theme">Design</string>
<string name="theme_system">System</string>
<string name="theme_light">Hell</string>
<string name="theme_dark">Dunkel</string>
<string name="n_minutes">%1$d Minuten</string>
<string name="n_seconds">%1$d Sekunden</string>
<string name="ch_ringing">Klingeln</string>
<string name="ch_upcoming">Anstehende Wecker</string>
<string name="ch_missed">Verpasste Wecker</string>
<string name="ch_running">Laufende Timer und Stoppuhr</string>
<string name="ch_bedtime">Schlafenszeit</string>
<string name="notif_upcoming">Wecker um %1$s</string>
<string name="notif_snoozed">Schlummert bis %1$s</string>
<string name="notif_missed">Wecker verpasst</string>
<string name="notif_bedtime">Bald ist Schlafenszeit</string>
<string name="notif_bedtime_text">Leg das Handy weg und komm zur Ruhe.</string>
<string name="fsi_title">Wecker im Vollbild</string>
<string name="fsi_text">Damit der Wecker auch über dem Sperrbildschirm erscheint, braucht die App die Erlaubnis für Vollbild-Benachrichtigungen. Ohne sie klingelt er trotzdem, aber nur als Benachrichtigung.</string>
<string name="no_alarm_set">Kein Wecker gestellt</string>
<string name="widget_digital_name">Uhrzeit</string>
<string name="widget_digital_desc">Große Uhrzeit, Datum und nächster Wecker</string>
<string name="widget_analog_name">Analoge Uhr</string>
<string name="widget_analog_desc">Zifferblatt in deinen Farben</string>
<string name="widget_next_name">Nächster Wecker</string>
<string name="widget_next_desc">Wann der nächste Wecker klingelt</string>
</resources>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/widget_analog_desc"
android:initialLayout="@layout/glance_default_loading_layout"
android:minWidth="110dp" android:minHeight="110dp" android:targetCellWidth="2" android:targetCellHeight="2"
android:minResizeWidth="80dp" android:minResizeHeight="40dp"
android:resizeMode="horizontal|vertical" android:updatePeriodMillis="0"
android:widgetCategory="home_screen|keyguard" />
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/widget_digital_desc"
android:initialLayout="@layout/glance_default_loading_layout"
android:minWidth="250dp" android:minHeight="110dp" android:targetCellWidth="4" android:targetCellHeight="2"
android:minResizeWidth="80dp" android:minResizeHeight="40dp"
android:resizeMode="horizontal|vertical" android:updatePeriodMillis="0"
android:widgetCategory="home_screen|keyguard" />
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/widget_next_desc"
android:initialLayout="@layout/glance_default_loading_layout"
android:minWidth="180dp" android:minHeight="48dp" android:targetCellWidth="3" android:targetCellHeight="1"
android:minResizeWidth="80dp" android:minResizeHeight="40dp"
android:resizeMode="horizontal|vertical" android:updatePeriodMillis="0"
android:widgetCategory="home_screen|keyguard" />
@@ -0,0 +1,44 @@
package app.uhr
import app.uhr.data.Alarm
import app.uhr.data.nextRing
import org.junit.Assert.assertEquals
import org.junit.Test
import java.time.LocalDate
import java.time.ZoneId
import java.time.ZonedDateTime
class NextRingTest {
private val berlin = ZoneId.of("Europe/Berlin")
private fun at(y: Int, m: Int, d: Int, h: Int, min: Int) = ZonedDateTime.of(y, m, d, h, min, 0, 0, berlin)
@Test fun onceLaterToday() = assertEquals(at(2026, 10, 16, 7, 0), nextRing(Alarm(hour = 7, minute = 0), at(2026, 10, 16, 6, 0)))
@Test fun onceTomorrowWhenPassed() = assertEquals(at(2026, 10, 17, 7, 0), nextRing(Alarm(hour = 7, minute = 0), at(2026, 10, 16, 7, 0)))
@Test fun weekdaysSkipWeekend() {
// Friday 2026-10-16 08:00 -> Monday
assertEquals(at(2026, 10, 19, 7, 0), nextRing(Alarm(hour = 7, minute = 0, days = Alarm.WEEKDAYS), at(2026, 10, 16, 8, 0)))
}
@Test fun pausedUntilDate() {
val a = Alarm(hour = 7, minute = 0, days = Alarm.EVERY_DAY, skipUntil = LocalDate.of(2026, 10, 20))
assertEquals(at(2026, 10, 21, 7, 0), nextRing(a, at(2026, 10, 16, 8, 0)))
}
@Test fun springForwardGapRingsAfterGap() {
// 2026-03-29 02:30 does not exist in Berlin
val r = nextRing(Alarm(hour = 2, minute = 30), at(2026, 3, 29, 1, 0))
assertEquals(3, r.hour); assertEquals(30, r.minute)
}
@Test fun fallBackKeepsWallClock() {
val r = nextRing(Alarm(hour = 7, minute = 0), at(2026, 10, 25, 1, 0))
assertEquals(at(2026, 10, 25, 7, 0), r)
}
@Test fun zoneChangeKeepsWallClock() {
val tokyo = ZonedDateTime.of(2026, 10, 16, 6, 0, 0, 0, ZoneId.of("Asia/Tokyo"))
val r = nextRing(Alarm(hour = 7, minute = 0), tokyo)
assertEquals(7, r.hour); assertEquals(ZoneId.of("Asia/Tokyo"), r.zone)
}
}
@@ -0,0 +1,138 @@
package app.uhr
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Alarm
import androidx.compose.material.icons.outlined.Bedtime
import androidx.compose.material.icons.outlined.HourglassEmpty
import androidx.compose.material.icons.outlined.Schedule
import androidx.compose.material.icons.outlined.Timer
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.MoreVert
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FabPosition
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.ui.unit.dp
import app.uhr.alarm.Ring
import app.uhr.alarm.RingingScreen
import app.uhr.data.Alarm
import app.uhr.data.AlarmInstance
import app.uhr.data.AlarmWithInstance
import app.uhr.data.Bedtime
import app.uhr.data.City
import app.uhr.data.Settings
import app.uhr.data.Stopwatch
import app.uhr.data.StopwatchState
import app.uhr.data.Timer
import app.uhr.data.TimerState
import app.uhr.data.nextRing
import app.uhr.ui.components.Fmt
import app.uhr.ui.screens.AlarmActions
import app.uhr.ui.screens.AlarmScreen
import app.uhr.ui.screens.BedtimeScreen
import app.uhr.ui.screens.ClockScreen
import app.uhr.ui.screens.SettingsScreen
import app.uhr.ui.screens.StopwatchScreen
import app.uhr.ui.screens.TimerActions
import app.uhr.ui.screens.TimerScreen
import app.uhr.ui.theme.StaticClockTheme
import com.github.takahirom.roborazzi.captureRoboImage
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import java.time.ZonedDateTime
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [35], qualifiers = "de-rDE-w412dp-h915dp-xxhdpi", application = android.app.Application::class)
class ScreenshotTest {
@get:Rule val compose = createComposeRule()
private val now = System.currentTimeMillis()
private fun inst(a: Alarm) = AlarmInstance(a.id, nextRing(a, ZonedDateTime.now()).toInstant().toEpochMilli())
private val alarms = listOf(
Alarm(id = 1, hour = 6, minute = 30, days = Alarm.WEEKDAYS, label = "Arbeit"),
Alarm(id = 2, hour = 8, minute = 0, days = 0b1100000, label = "Wochenende"),
Alarm(id = 3, hour = 14, minute = 45, label = "Paket abholen", enabled = false),
).map { AlarmWithInstance(it, if (it.enabled) inst(it) else null) }
private val noAlarms = object : AlarmActions {
override fun save(alarm: Alarm) {}; override fun delete(alarm: Alarm) {}
override fun restore(alarm: Alarm) {}; override fun dismissNext(alarm: Alarm) {}
}
private val noTimers = object : TimerActions {
override fun start(durationMs: Long) {}; override fun pause(id: Long) {}; override fun resume(id: Long) {}
override fun addMinute(id: Long) {}; override fun reset(id: Long) {}; override fun delete(id: Long) {}; override fun stopRinging() {}
}
private fun shot(name: String, dark: Boolean, content: @Composable () -> Unit) {
Fmt.is24h = true
compose.setContent { StaticClockTheme(dark) { content() } }
compose.onRoot().captureRoboImage("../../replica/uhr/clone-screens/$name.png")
}
/** Same frame as MainActivity: title, bottom navigation, large centred FAB. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun Frame(tab: Int, fab: Boolean = true, body: @Composable (PaddingValues) -> Unit) {
val tabs = listOf("Wecker" to Icons.Outlined.Alarm, "Uhr" to Icons.Outlined.Schedule, "Timer" to Icons.Outlined.HourglassEmpty, "Stoppuhr" to Icons.Outlined.Timer, "Schlafen" to Icons.Outlined.Bedtime)
Scaffold(
topBar = { TopAppBar({ Text(tabs[tab].first) }, actions = { Icon(Icons.Rounded.MoreVert, null) }, colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surface)) },
bottomBar = { NavigationBar { tabs.forEachIndexed { i, t -> NavigationBarItem(i == tab, {}, { Icon(t.second, null) }, label = { Text(t.first) }) } } },
floatingActionButton = {
if (fab) FloatingActionButton({}, shape = CircleShape, modifier = Modifier.size(96.dp), containerColor = app.uhr.ui.theme.fabColors().first, contentColor = app.uhr.ui.theme.fabColors().second) { Icon(Icons.Rounded.Add, null, Modifier.size(36.dp)) }
},
floatingActionButtonPosition = FabPosition.Center,
) { body(it) }
}
@Test fun s01_alarms_dark() = shot("S01-alarms-dark", true) { Frame(0) { AlarmScreen(alarms, Settings(), noAlarms, SnackbarHostState(), it) } }
@Test fun s01_alarms_light() = shot("S01-alarms-light", false) { Frame(0) { AlarmScreen(alarms, Settings(), noAlarms, SnackbarHostState(), it) } }
@Test fun s01_empty() = shot("S01-empty", true) { Frame(0) { AlarmScreen(emptyList(), Settings(), noAlarms, SnackbarHostState(), it) } }
@Test fun s02_expanded() = shot("S02-expanded-dark", true) { Frame(0) { AlarmScreen(alarms, Settings(), noAlarms, SnackbarHostState(), it, initiallyExpanded = 1) } }
@Test fun s06_clock() = shot("S06-clock-dark", true) {
val cities = listOf(City(1, "America/New_York", "New York"), City(2, "Asia/Tokyo", "Tokio"), City(3, "Europe/London", "London"))
Frame(1) { ClockScreen(Settings(), cities, alarms[0].instance, it, {}, nowOverride = now) }
}
@Test fun s06_analog() = shot("S06-analog-light", false) {
Frame(1) { ClockScreen(Settings(clockStyle = app.uhr.data.ClockStyle.ANALOG, showSeconds = true), listOf(City(1, "Asia/Tokyo", "Tokio")), null, it, {}, nowOverride = now) }
}
@Test fun s08_keypad() = shot("S08-keypad-dark", true) { Frame(2) { TimerScreen(emptyList(), true, "530", {}, noTimers, it) } }
@Test fun s08_timers() = shot("S08-timers-dark", true) {
val timers = listOf(
Timer(1, "", 330_000, 0, now + 285_000, TimerState.RUNNING),
Timer(2, "Pizza", 3_600_000, 3_585_000, null, TimerState.PAUSED),
)
Frame(2) { TimerScreen(timers, false, "", {}, noTimers, it, nowOverride = now) }
}
@Test fun s09_stopwatch() = shot("S09-stopwatch-dark", true) {
val sw = Stopwatch(StopwatchState.RUNNING, startedAt = 0, accumulatedMs = 0, laps = listOf(15_980, 31_200, 49_870))
Frame(3, fab = false) { StopwatchScreen(sw, {}, {}, {}, it, nowOverride = 63_420) }
}
@Test fun s10_bedtime() = shot("S10-bedtime-dark", true) { Frame(4, fab = false) { BedtimeScreen(Bedtime(enabled = true, bedHour = 22, bedMinute = 45, wakeHour = 6, wakeMinute = 30), Settings(), {}, it) } }
@Test fun s10_intro() = shot("S10-intro-light", false) { Frame(4, fab = false) { BedtimeScreen(Bedtime(), Settings(), {}, it) } }
@Test fun s13_settings() = shot("S13-settings-dark", true) { SettingsScreen(Settings(), {}, {}) }
@Test fun s11_ringing() = shot("S11-ringing", true) { RingingScreen(Ring.AlarmRing(Alarm(hour = 6, minute = 30, label = "Arbeit")), {}, {}) }
}