diff --git a/CHANGELOG.md b/CHANGELOG.md
index dd7bc36..fdf438a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -33,8 +33,10 @@ The first release of Clockula: alarms, timers, a stopwatch and a world clock.
can't silence an alarm.
- **Timers.** Run several at once, each with its own label, length and sound,
set from a keypad with saved presets. Pause, resume, reset and +1 min work
- from the timer, from the live pill and from the notification. Timers keep
- counting through a reboot and aren't affected by changes to the clock.
+ from the timer, from the live pill and from the notification. A finished
+ timer turns the screen on and opens over the lock screen with Stop and
+ +1 min. Timers keep counting through a reboot and aren't affected by changes
+ to the clock.
- **Stopwatch** with laps, the fastest and slowest marked, and Lap and Pause
in the notification. It keeps counting while the app is closed.
- **World clock.** An analog face for your own time, plus cities from the
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 1160653..139dc39 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -183,6 +183,18 @@
android:taskAffinity=""
android:turnScreenOn="true" />
+
+
+
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/AlarmRingService.kt b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/AlarmRingService.kt
index 1f659ce..f41714a 100644
--- a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/AlarmRingService.kt
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/AlarmRingService.kt
@@ -109,10 +109,12 @@ class AlarmRingService : Service() {
/**
* The placeholder the service goes foreground with immediately; [ring]
* replaces it with the alarm's own notification as soon as the session is
- * read. Same channel and same id, so the user sees one notification.
+ * read. Same channel and same id, so the user sees one notification — and
+ * the placeholder is the first post, so it is the one that opens the ring
+ * screen.
*/
private fun startForegroundPreparing() {
- startForeground(RingNotifications.preparing(this))
+ startForeground(RingNotifications.preparing(this, presentation()))
}
private fun startForeground(session: RingSession) {
@@ -120,11 +122,13 @@ class AlarmRingService : Service() {
RingNotifications.ringing(
context = this,
alarm = session.alarm,
- presentation = RingPresentationPolicy.forCapabilities(capabilities.snapshot()),
+ presentation = presentation(),
),
)
}
+ private fun presentation() = RingPresentationPolicy.forCapabilities(capabilities.snapshot())
+
private fun startForeground(notification: Notification) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
// systemExempted: Android's own guidance names an app holding an
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/RingNotifications.kt b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/RingNotifications.kt
index 7a67b79..980a2b2 100644
--- a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/RingNotifications.kt
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/RingNotifications.kt
@@ -72,9 +72,17 @@ object RingNotifications {
* The notification the service goes foreground with the instant it starts,
* before it has read anything: `startForeground` must not wait on a database
* read. [ringing] replaces it under the same id a moment later.
+ *
+ * It already carries the full-screen intent. SystemUI launches one only
+ * when a notification is first *posted*, never on an update, so an intent
+ * that only [ringing] carried would leave a locked phone ringing behind a
+ * black screen (issue #15). The ring screen reads the session itself and
+ * closes when there is none, so a dismiss that lands before the service's
+ * read costs one brief screen wake, never a ring screen left up.
*/
- fun preparing(context: Context): Notification =
- NotificationCompat.Builder(context, RING_CHANNEL_ID)
+ fun preparing(context: Context, presentation: RingPresentation): Notification {
+ val show = showPendingIntent(context)
+ val builder = NotificationCompat.Builder(context, RING_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(context.getString(R.string.alarm_notification_title))
.setCategory(NotificationCompat.CATEGORY_ALARM)
@@ -82,8 +90,10 @@ object RingNotifications {
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setOngoing(true)
.setAutoCancel(false)
- .setContentIntent(showPendingIntent(context))
- .build()
+ .setContentIntent(show)
+ if (presentation.useFullScreenIntent) builder.setFullScreenIntent(show, true)
+ return builder.build()
+ }
/** The ringing notification, presented as [presentation] says the platform allows. */
fun ringing(
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/timer/TimerIntents.kt b/app/src/main/java/de/jeanlucmakiola/clockula/timer/TimerIntents.kt
index 723155a..04e3a23 100644
--- a/app/src/main/java/de/jeanlucmakiola/clockula/timer/TimerIntents.kt
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/timer/TimerIntents.kt
@@ -24,6 +24,7 @@ object TimerIntents {
const val REQUEST_RESUME: Int = 14
const val REQUEST_RESET: Int = 15
const val REQUEST_ADD_TIME: Int = 16
+ const val REQUEST_RING: Int = 17
val ALL_REQUEST_CODES: List = listOf(
REQUEST_EXPIRY,
@@ -32,6 +33,7 @@ object TimerIntents {
REQUEST_RESUME,
REQUEST_RESET,
REQUEST_ADD_TIME,
+ REQUEST_RING,
)
/** base * 100_000 + (timerId % 100_000) — the stride `RingNotifications` already uses. */
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/timer/service/TimerNotifications.kt b/app/src/main/java/de/jeanlucmakiola/clockula/timer/service/TimerNotifications.kt
index af07e3d..0c74381 100644
--- a/app/src/main/java/de/jeanlucmakiola/clockula/timer/service/TimerNotifications.kt
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/timer/service/TimerNotifications.kt
@@ -16,6 +16,7 @@ import de.jeanlucmakiola.clockula.domain.timer.TimerNotificationAction
import de.jeanlucmakiola.clockula.domain.timer.TimerNotificationState
import de.jeanlucmakiola.clockula.timer.TimerIntents
import de.jeanlucmakiola.clockula.timer.receiver.TimerActionReceiver
+import de.jeanlucmakiola.clockula.ui.ring.TimerRingActivity
/**
* One channel and one id for however many timers: the foreground service's
@@ -30,9 +31,20 @@ object TimerNotifications {
const val TIMER_CHANNEL_ID: String = "timers"
- /** One notification for every timer (D17), so one id. */
+ /** One notification for every timer (D17), so one id at a time — of these two. */
const val TIMER_NOTIFICATION_ID: Int = 1_002
+ /**
+ * The id an expiry's alerting post swaps to, and back from on the next one.
+ * SystemUI launches a full-screen intent only for a newly *posted*
+ * notification, and an expiry is otherwise an update of the countdown's.
+ */
+ const val TIMER_ALERT_NOTIFICATION_ID: Int = 1_004
+
+ /** The id an alerting post goes up under when [current] is on screen. */
+ fun alertingIdAfter(current: Int): Int =
+ if (current == TIMER_NOTIFICATION_ID) TIMER_ALERT_NOTIFICATION_ID else TIMER_NOTIFICATION_ID
+
/** Idempotent; called from the app class on every start. */
fun createChannel(context: Context) {
val channel = NotificationChannel(
@@ -75,8 +87,12 @@ object TimerNotifications {
.setContentIntent(showPendingIntent(context))
.build()
- /** The subject, and a count. Two quick actions; the app row also offers subtraction. */
- fun timer(context: Context, state: TimerNotificationState): Notification {
+ /**
+ * The subject, and a count. Two quick actions; the app row also offers
+ * subtraction. An expiry's one alerting post also opens [TimerRingActivity]
+ * over the lock screen when [fullScreen] allows it.
+ */
+ fun timer(context: Context, state: TimerNotificationState, fullScreen: Boolean): Notification {
val builder = NotificationCompat.Builder(context, TIMER_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(
@@ -130,6 +146,11 @@ object TimerNotifications {
builder.setShowWhen(false).setContentText(bodyFor(context, state))
}
+ if (fullScreen && state.mode == TimerMode.EXPIRED && !state.alertOnce) {
+ builder.setPriority(NotificationCompat.PRIORITY_MAX)
+ .setFullScreenIntent(ringPendingIntent(context), true)
+ }
+
state.actions.forEach { action ->
builder.addAction(
R.drawable.ic_notification,
@@ -179,6 +200,14 @@ object TimerNotifications {
PENDING_INTENT_FLAGS,
)
+ private fun ringPendingIntent(context: Context): PendingIntent = PendingIntent.getActivity(
+ context,
+ TimerIntents.REQUEST_RING,
+ Intent(context, TimerRingActivity::class.java)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK),
+ PENDING_INTENT_FLAGS,
+ )
+
private fun actionPendingIntent(
context: Context,
action: TimerNotificationAction,
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/timer/service/TimerService.kt b/app/src/main/java/de/jeanlucmakiola/clockula/timer/service/TimerService.kt
index 8c215a9..f4ec018 100644
--- a/app/src/main/java/de/jeanlucmakiola/clockula/timer/service/TimerService.kt
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/timer/service/TimerService.kt
@@ -10,6 +10,7 @@ import android.os.IBinder
import android.os.PowerManager
import androidx.core.app.NotificationManagerCompat
import dagger.hilt.android.AndroidEntryPoint
+import de.jeanlucmakiola.clockula.alarm.AlarmCapabilities
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.clockula.data.timers.TimerRepository
import de.jeanlucmakiola.clockula.domain.ring.RingFallbackPolicy
@@ -71,6 +72,9 @@ class TimerService : Service() {
@Inject
lateinit var settings: SettingsPrefs
+ @Inject
+ lateinit var capabilities: AlarmCapabilities
+
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val audio by lazy { RingAudioPlayer(this) }
@@ -105,6 +109,13 @@ class TimerService : Service() {
*/
private var wentForeground: Boolean = false
+ /**
+ * The id the notification is up under. An alerting post moves it to the
+ * other of the two, so an expiry is a *new* notification and its
+ * full-screen intent fires — every other post keeps it where it is.
+ */
+ private var notificationId: Int = TimerNotifications.TIMER_NOTIFICATION_ID
+
/** The last start command's id, so [stop] can decline to die on a newer one. */
private var lastStartId: Int = 0
@@ -170,7 +181,7 @@ class TimerService : Service() {
}
applyRing(state.ring)
- post(notification)
+ post(notification, sounding = state.ring is TimerRingDecision.Sound)
scheduleDelays(state)
progressRefresh?.cancel()
progressRefresh = if (notification.mode == TimerMode.RUNNING) {
@@ -239,9 +250,20 @@ class TimerService : Service() {
}
}
- private fun post(state: TimerNotificationState) {
- if (!state.alertOnce) alreadyAlerted = true
- startForeground(TimerNotifications.timer(this, state))
+ private fun post(state: TimerNotificationState, sounding: Boolean) {
+ var fullScreen = false
+ if (!state.alertOnce) {
+ alreadyAlerted = true
+ // Moving the foreground notification to a new id removes the old one,
+ // so the user still sees one notification.
+ notificationId = TimerNotifications.alertingIdAfter(notificationId)
+ // Only a sounding timer opens its ring screen: one suppressed by a
+ // ringing alarm would cover the alarm's screen (D9), and one that
+ // auto-silenced before a process restart would wake the display for
+ // nothing (D11).
+ fullScreen = sounding && capabilities.snapshot().canUseFullScreenIntent
+ }
+ startForeground(TimerNotifications.timer(this, state, fullScreen))
}
/**
@@ -279,12 +301,12 @@ class TimerService : Service() {
// ringing service: an app holding an exact-alarm permission,
// continuing an alarm-shaped event in the background (D6).
startForeground(
- TimerNotifications.TIMER_NOTIFICATION_ID,
+ notificationId,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED,
)
} else {
- startForeground(TimerNotifications.TIMER_NOTIFICATION_ID, notification)
+ startForeground(notificationId, notification)
}
}
@@ -314,7 +336,11 @@ class TimerService : Service() {
stopForeground(STOP_FOREGROUND_REMOVE)
// The placeholder is posted before anything is read, so a service that
// starts with nothing active must take its own notification with it.
- NotificationManagerCompat.from(this).cancel(TimerNotifications.TIMER_NOTIFICATION_ID)
+ NotificationManagerCompat.from(this).apply {
+ cancel(TimerNotifications.TIMER_NOTIFICATION_ID)
+ cancel(TimerNotifications.TIMER_ALERT_NOTIFICATION_ID)
+ }
+ notificationId = TimerNotifications.TIMER_NOTIFICATION_ID
wentForeground = false
// Not `stopSelf()`: resetting the last timer and immediately starting a
// new one delivers a start command to this still-live instance, and
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/TimerRingActivity.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/TimerRingActivity.kt
new file mode 100644
index 0000000..2369bae
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/TimerRingActivity.kt
@@ -0,0 +1,63 @@
+package de.jeanlucmakiola.clockula.ui.ring
+
+import android.os.Bundle
+import android.view.WindowManager
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.BackHandler
+import androidx.activity.compose.setContent
+import androidx.activity.viewModels
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import dagger.hilt.android.AndroidEntryPoint
+import de.jeanlucmakiola.clockula.ui.theme.ClockulaTheme
+
+/**
+ * The window an expired timer gets, opened by the timer notification's
+ * full-screen intent: over the lock screen with the display turned on, for as
+ * long as any timer has expired. Without it, a timer finishing on a locked
+ * phone rang behind a black screen (issue #15).
+ *
+ * Deliberately not [de.jeanlucmakiola.clockula.MainActivity]: that would put
+ * the whole app over the lock screen.
+ */
+@AndroidEntryPoint
+class TimerRingActivity : ComponentActivity() {
+
+ private val viewModel: TimerRingViewModel by viewModels()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ // As AlarmRingActivity: shown over the lock screen, display on, and
+ // kept on for this window's life only.
+ setShowWhenLocked(true)
+ setTurnScreenOn(true)
+ window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+
+ setContent {
+ ClockulaTheme {
+ BackHandler(enabled = true) { /* A timer is not stopped by leaving. */ }
+
+ val state by viewModel.state.collectAsStateWithLifecycle()
+ when (val current = state) {
+ TimerRingUiState.Loading ->
+ Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {}
+
+ is TimerRingUiState.Ringing -> TimerRingScreen(
+ state = current,
+ onStop = viewModel::onStop,
+ onAddMinute = viewModel::onAddMinute,
+ )
+
+ // Stopped here, from the shade or from the Timers tab, or
+ // auto-silenced: whoever ended the ring closes this window.
+ TimerRingUiState.Finished -> LaunchedEffect(Unit) { finish() }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/TimerRingScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/TimerRingScreen.kt
new file mode 100644
index 0000000..0beb8e3
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/TimerRingScreen.kt
@@ -0,0 +1,112 @@
+package de.jeanlucmakiola.clockula.ui.ring
+
+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.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeDrawingPadding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Timer
+import androidx.compose.material3.Button
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.scale
+import androidx.compose.ui.res.pluralStringResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import de.jeanlucmakiola.clockula.R
+import de.jeanlucmakiola.floret.identity.rememberReduceMotion
+
+/**
+ * The timer ring screen: the label, "Finished", and the notification's two
+ * buttons. Back is swallowed at [TimerRingActivity]'s composition root, for the
+ * same reason the alarm's is.
+ */
+@Composable
+internal fun TimerRingScreen(
+ state: TimerRingUiState.Ringing,
+ onStop: () -> Unit,
+ onAddMinute: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Surface(modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {
+ Column(
+ modifier = Modifier.fillMaxSize().safeDrawingPadding().padding(24.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ TimerBadge()
+ Text(
+ text = stringResource(R.string.timer_finished),
+ style = MaterialTheme.typography.displayMedium,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ Text(
+ text = state.label.ifBlank { stringResource(R.string.timer_notification_title) },
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ if (state.otherExpired > 0) {
+ Text(
+ text = pluralStringResource(R.plurals.timer_more_active, state.otherExpired, state.otherExpired),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
+ OutlinedButton(onClick = onAddMinute) {
+ Text(stringResource(R.string.timer_add_minute))
+ }
+ Button(onClick = onStop) {
+ Text(stringResource(R.string.timer_stop))
+ }
+ }
+ }
+ }
+}
+
+/** The alarm screen's pulsing badge, with the timer's icon. */
+@Composable
+private fun TimerBadge() {
+ val reduceMotion = rememberReduceMotion()
+ val pulse = rememberInfiniteTransition(label = "timer-ring-pulse")
+ val scale by pulse.animateFloat(
+ initialValue = 1f,
+ targetValue = if (reduceMotion) 1f else 1.08f,
+ animationSpec = infiniteRepeatable(tween(900), RepeatMode.Reverse),
+ label = "timer-ring-pulse-scale",
+ )
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .size(96.dp)
+ .scale(scale)
+ .background(MaterialTheme.colorScheme.primaryContainer, CircleShape),
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.Timer,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onPrimaryContainer,
+ modifier = Modifier.size(48.dp),
+ )
+ }
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/TimerRingUiState.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/TimerRingUiState.kt
new file mode 100644
index 0000000..91bbf70
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/TimerRingUiState.kt
@@ -0,0 +1,18 @@
+package de.jeanlucmakiola.clockula.ui.ring
+
+sealed interface TimerRingUiState {
+ /** The timers have not been read yet. Every action is ignored. */
+ data object Loading : TimerRingUiState
+
+ data class Ringing(
+ /** The timer the screen speaks for: the first expired one, in subject order. */
+ val timerId: Long,
+ /** Verbatim; blank stays blank — the screen supplies the fallback string. */
+ val label: String,
+ /** Expired timers besides [timerId]; each gets its own Stop once this one stops. */
+ val otherExpired: Int,
+ ) : TimerRingUiState
+
+ /** Nothing has expired any more. The activity finishes. */
+ data object Finished : TimerRingUiState
+}
diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/TimerRingViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/TimerRingViewModel.kt
new file mode 100644
index 0000000..a5fd38d
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/ring/TimerRingViewModel.kt
@@ -0,0 +1,88 @@
+package de.jeanlucmakiola.clockula.ui.ring
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import de.jeanlucmakiola.clockula.data.timers.TimerRepository
+import de.jeanlucmakiola.clockula.data.timers.TimerRingStateStore
+import de.jeanlucmakiola.clockula.domain.time.ElapsedRealtimeClock
+import de.jeanlucmakiola.clockula.domain.time.Ticker
+import de.jeanlucmakiola.clockula.domain.time.WallClock
+import de.jeanlucmakiola.clockula.domain.timer.TimerReadings
+import de.jeanlucmakiola.clockula.domain.timer.TimerRing
+import de.jeanlucmakiola.clockula.timer.TimerEngine
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import kotlin.time.Duration.Companion.seconds
+import javax.inject.Inject
+
+/**
+ * The timer ring screen's behaviour: which expired timer it speaks for, and the
+ * same two verbs as the notification's buttons, through the engine — so the
+ * screen, the shade and the Timers tab can never disagree about what Stop does.
+ */
+@HiltViewModel
+class TimerRingViewModel @Inject constructor(
+ timers: TimerRepository,
+ ringState: TimerRingStateStore,
+ private val engine: TimerEngine,
+ elapsed: ElapsedRealtimeClock,
+ wall: WallClock,
+ ticker: Ticker,
+) : ViewModel() {
+
+ /**
+ * Re-read on a tick as well as on every write: a row stored RUNNING *reads*
+ * EXPIRED once its time is up, before the engine's sweep has written it, and
+ * the auto-silence window ends without anything being written at all.
+ */
+ val state: StateFlow =
+ combine(timers.timers(), ringState.soundingSince, ticker.ticks(TICK)) { rows, soundingSince, _ ->
+ val now = wall.now()
+ val expired = TimerReadings.expired(
+ TimerReadings.active(rows, elapsed.elapsedRealtime(), now),
+ )
+ val subject = expired.firstOrNull()
+ // Auto-silence leaves the rows EXPIRED (D11) but ends the ring, and a
+ // screen held on over a silent timer is only a battery drain.
+ val silenced = soundingSince != null && now - soundingSince >= TimerRing.AUTO_SILENCE_AFTER
+ if (subject == null || silenced) {
+ TimerRingUiState.Finished
+ } else {
+ TimerRingUiState.Ringing(
+ timerId = subject.timer.id,
+ label = subject.timer.label,
+ otherExpired = expired.size - 1,
+ )
+ }
+ }
+ .distinctUntilChanged()
+ // Stops a while after the screen goes to the background, so a ring
+ // screen left behind Home does not tick forever; the grace period
+ // covers a rotation without flashing back to Loading.
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), TimerRingUiState.Loading)
+
+ /**
+ * "Stop": the notification's RESET on an expired timer. The guarded verb,
+ * because the id may be one emission old: a "+1 min" from here or the shade
+ * can have set the timer running again, and that countdown must survive.
+ */
+ fun onStop() {
+ val ringing = state.value as? TimerRingUiState.Ringing ?: return
+ viewModelScope.launch { engine.dismissExpired(ringing.timerId) }
+ }
+
+ fun onAddMinute() {
+ val ringing = state.value as? TimerRingUiState.Ringing ?: return
+ viewModelScope.launch { engine.addTime(ringing.timerId) }
+ }
+
+ private companion object {
+ val TICK = 1.seconds
+ const val STOP_TIMEOUT_MS = 5_000L
+ }
+}
diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/ring/TimerRingViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/ring/TimerRingViewModelTest.kt
new file mode 100644
index 0000000..93a42ed
--- /dev/null
+++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/ring/TimerRingViewModelTest.kt
@@ -0,0 +1,134 @@
+package de.jeanlucmakiola.clockula.ui.ring
+
+import com.google.common.truth.Truth.assertThat
+import de.jeanlucmakiola.clockula.domain.TimerState
+import de.jeanlucmakiola.clockula.domain.timer.TimerRing
+import de.jeanlucmakiola.clockula.testing.MainDispatcherExtension
+import de.jeanlucmakiola.clockula.testing.T0
+import de.jeanlucmakiola.clockula.testing.TimerEngineHarness
+import de.jeanlucmakiola.clockula.testing.expiredTimer
+import de.jeanlucmakiola.clockula.testing.runningTimerWith
+import de.jeanlucmakiola.clockula.testing.timerEngineHarness
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.RegisterExtension
+import org.junit.jupiter.api.io.TempDir
+import java.nio.file.Path
+import kotlin.time.Duration.Companion.minutes
+import kotlin.time.Duration.Companion.seconds
+
+/**
+ * The timer ring screen over the **real** timer engine: which timer it speaks
+ * for, that its buttons are the notification's verbs, and that it closes
+ * whenever the ring ends — including the auto-silence that leaves rows EXPIRED.
+ */
+class TimerRingViewModelTest {
+
+ @JvmField
+ @RegisterExtension
+ val main = MainDispatcherExtension()
+
+ /** Subscribed for the whole test, as the visible screen is: the state is shared while observed. */
+ private fun TestScope.viewModel(harness: TimerEngineHarness) = TimerRingViewModel(
+ timers = harness.timers,
+ ringState = harness.ringState,
+ engine = harness.engine,
+ elapsed = harness.elapsed,
+ wall = harness.wallClock,
+ ticker = harness.ticker,
+ ).also { viewModel -> backgroundScope.launch { viewModel.state.collect {} } }
+
+ @Test
+ fun `an expired timer rings, with the other expired ones counted`(@TempDir tempDir: Path) =
+ runTest(main.dispatcher) {
+ val harness = timerEngineHarness(tempDir)
+ harness.given(expiredTimer(id = 1L, label = "Pasta", sortOrder = 0))
+ harness.given(expiredTimer(id = 2L, label = "Eggs", sortOrder = 1))
+
+ val viewModel = viewModel(harness)
+ advanceUntilIdle()
+
+ assertThat(viewModel.state.value)
+ .isEqualTo(TimerRingUiState.Ringing(timerId = 1L, label = "Pasta", otherExpired = 1))
+ }
+
+ @Test
+ fun `nothing expired finishes the screen`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
+ val harness = timerEngineHarness(tempDir)
+ harness.given(runningTimerWith(id = 1L, left = 3.minutes, elapsedNow = harness.elapsed.value))
+
+ val viewModel = viewModel(harness)
+ advanceUntilIdle()
+
+ assertThat(viewModel.state.value).isEqualTo(TimerRingUiState.Finished)
+ }
+
+ @Test
+ fun `stop resets the timer and the screen moves on to the next expired one`(@TempDir tempDir: Path) =
+ runTest(main.dispatcher) {
+ val harness = timerEngineHarness(tempDir)
+ harness.given(expiredTimer(id = 1L, label = "Pasta", sortOrder = 0))
+ harness.given(expiredTimer(id = 2L, label = "Eggs", sortOrder = 1))
+ val viewModel = viewModel(harness)
+ advanceUntilIdle()
+
+ viewModel.onStop()
+ advanceUntilIdle()
+
+ assertThat(harness.stored(1L)!!.state).isEqualTo(TimerState.IDLE)
+ assertThat(viewModel.state.value)
+ .isEqualTo(TimerRingUiState.Ringing(timerId = 2L, label = "Eggs", otherExpired = 0))
+ }
+
+ @Test
+ fun `stop leaves a timer that is running again alone`(@TempDir tempDir: Path) =
+ runTest(main.dispatcher) {
+ val harness = timerEngineHarness(tempDir)
+ harness.given(expiredTimer(id = 1L))
+ val viewModel = viewModel(harness)
+ advanceUntilIdle()
+
+ // "+1 min" from the shade lands before the screen has re-read the rows.
+ harness.engine.addTime(1L)
+ viewModel.onStop()
+ advanceUntilIdle()
+
+ assertThat(harness.stored(1L)!!.state).isEqualTo(TimerState.RUNNING)
+ }
+
+ @Test
+ fun `plus one minute runs the timer again and finishes the screen`(@TempDir tempDir: Path) =
+ runTest(main.dispatcher) {
+ val harness = timerEngineHarness(tempDir)
+ harness.given(expiredTimer(id = 1L))
+ val viewModel = viewModel(harness)
+ advanceUntilIdle()
+
+ viewModel.onAddMinute()
+ advanceUntilIdle()
+
+ assertThat(harness.stored(1L)!!.state).isEqualTo(TimerState.RUNNING)
+ assertThat(viewModel.state.value).isEqualTo(TimerRingUiState.Finished)
+ }
+
+ @Test
+ fun `an auto-silenced ring finishes the screen though the row stays expired`(@TempDir tempDir: Path) =
+ runTest(main.dispatcher) {
+ val harness = timerEngineHarness(tempDir)
+ harness.given(expiredTimer(id = 1L))
+ harness.ringState.set(T0)
+ val viewModel = viewModel(harness)
+ advanceUntilIdle()
+ assertThat(viewModel.state.value).isInstanceOf(TimerRingUiState.Ringing::class.java)
+
+ harness.wallClock.advance(TimerRing.AUTO_SILENCE_AFTER + 1.seconds)
+ harness.ticker.tick()
+ advanceUntilIdle()
+
+ assertThat(harness.stored(1L)!!.state).isEqualTo(TimerState.EXPIRED)
+ assertThat(viewModel.state.value).isEqualTo(TimerRingUiState.Finished)
+ }
+}