feat(shell): the navigation host, four tabs and the live pill

`NavigationSuiteScaffold` gives the bar on a phone and the rail on a wide
layout from one declaration. Each tab keeps its own back stack; re-selecting
a tab returns to its root, and predictive back is wired only where back
actually goes somewhere.

The pill is the running-state surface from PLAN §9: it floats above the bar,
speaks for whichever subject is closest to needing attention, and pauses or
stops it from any tab. Its callbacks are gated on a live subject, so a second
tap during its exit animation cannot restart a timer the user just reset.

The notification permission is asked once, and the flag is written from the
request's result rather than before it — a process death mid-dialog leaves
the ask due again rather than recorded and never made. Three tabs are empty
shells until M5 through M8 fill them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wmy1BpCKi8KeSjaWhYuCPV
This commit is contained in:
2026-09-12 13:07:18 +02:00
parent 199005e795
commit 83c5e9fe3f
21 changed files with 1544 additions and 28 deletions
@@ -8,22 +8,18 @@ import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.clockula.ui.crash.CrashReportActivity
import de.jeanlucmakiola.clockula.ui.shell.ClockulaShell
import de.jeanlucmakiola.clockula.ui.shell.RequestNotificationPermissionOnce
import de.jeanlucmakiola.clockula.ui.theme.ClockulaTheme
import de.jeanlucmakiola.floret.crash.CrashReportDialog
import de.jeanlucmakiola.floret.crash.CrashReporter
@@ -33,9 +29,11 @@ import de.jeanlucmakiola.floret.prefs.isDark
import javax.inject.Inject
/**
* Single activity. M1 scaffolding: it brings up the theme — now following the
* stored appearance preference — and the crash surface, and nothing else. The
* four-tab shell and the live pill (docs/PLAN.md §9) land in M4.
* Single activity: the theme — following the stored appearance preference — the
* crash surface, and the four-tab shell with its live pill (docs/PLAN.md §9).
* Every screen above it is a destination inside that shell's `NavHost`; the
* ring screen is the one deliberate exception, since it has to come up over a
* lock screen on its own.
*/
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@@ -84,7 +82,10 @@ class MainActivity : ComponentActivity() {
darkTheme = darkTheme,
dynamicColor = appearance.dynamicColor,
) {
Placeholder(Modifier.fillMaxSize())
ClockulaShell(Modifier.fillMaxSize())
// First launch asks for notifications once, and only here:
// it belongs to starting the app, not to any one tab.
RequestNotificationPermissionOnce(settingsPrefs)
pendingCrashReport?.let { report ->
CrashReportDialog(
report = report,
@@ -118,24 +119,6 @@ class MainActivity : ComponentActivity() {
}
}
/** Stands in for the app shell until M4. Proves the theme resolves and renders. */
@Composable
private fun Placeholder(modifier: Modifier = Modifier) {
Surface(modifier, color = MaterialTheme.colorScheme.background) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
text = stringResourceAppName(),
style = MaterialTheme.typography.displaySmall,
color = MaterialTheme.colorScheme.primary,
)
}
}
}
/** The scrims androidx uses for a three-button navigation bar, matched exactly. */
private val NAV_BAR_LIGHT_SCRIM = Color.argb(0xe6, 0xFF, 0xFF, 0xFF)
private val NAV_BAR_DARK_SCRIM = Color.argb(0x80, 0x1b, 0x1b, 0x1b)
@Composable
private fun stringResourceAppName(): String =
androidx.compose.ui.res.stringResource(R.string.app_name)
@@ -0,0 +1,17 @@
package de.jeanlucmakiola.clockula.ui.alarms
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.ui.shell.EmptyTabScreen
/** A top-level tab: its title bar and its empty state. The alarm list is M5's. */
@Composable
fun AlarmsScreen(modifier: Modifier = Modifier) {
EmptyTabScreen(
title = stringResource(R.string.tab_alarms),
message = stringResource(R.string.alarms_empty),
modifier = modifier,
)
}
@@ -0,0 +1,12 @@
package de.jeanlucmakiola.clockula.ui.shell
import androidx.annotation.StringRes
import de.jeanlucmakiola.clockula.R
/** The shell's four top-level destinations, in tab order. No Compose types. */
enum class ClockulaDestination(val route: String, @StringRes val titleRes: Int) {
ALARMS("alarms", R.string.tab_alarms),
TIMERS("timers", R.string.tab_timers),
STOPWATCH("stopwatch", R.string.tab_stopwatch),
WORLD_CLOCK("world_clock", R.string.tab_world_clock),
}
@@ -0,0 +1,141 @@
package de.jeanlucmakiola.clockula.ui.shell
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteItem
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import de.jeanlucmakiola.clockula.ui.alarms.AlarmsScreen
import de.jeanlucmakiola.clockula.ui.stopwatch.StopwatchScreen
import de.jeanlucmakiola.clockula.ui.timers.TimersScreen
import de.jeanlucmakiola.clockula.ui.worldclock.WorldClockScreen
import de.jeanlucmakiola.floret.identity.fadeThrough
import de.jeanlucmakiola.floret.identity.predictiveBack
/**
* The adaptive navigation shell: four tabs, the live pill, predictive back.
*
* The navigation component itself is the M3 Expressive one chosen by window
* size — a short navigation bar on a phone, a wide rail on a tablet — because
* `NavigationSuiteScaffold`'s default already answers the tabletop posture and
* the compact-*height* case that an `if (compact) bar else rail` would miss.
*
* The `NavHost` is the single source of truth for the selected tab: Navigation
* Compose already saves its back stack through a `SavedStateHandle`, so the
* selection survives rotation and process death with no mirror of our own.
*/
@Composable
fun ClockulaShell(
modifier: Modifier = Modifier,
viewModel: ShellViewModel = hiltViewModel(),
navController: NavHostController = rememberNavController(),
) {
val pill by viewModel.livePill.collectAsStateWithLifecycle()
val entry by navController.currentBackStackEntryAsState()
val route = entry?.destination?.route
val selected = ShellNavigation.destinationOf(route) ?: ShellNavigation.start
val back = ShellNavigation.onBack(route)
// Peer destinations have no spatial relationship, so tabs fade through
// rather than sliding: a slide would claim a hierarchy that is not there.
val fade = fadeThrough()
NavigationSuiteScaffold(
navigationItems = {
ShellNavigation.topLevel.forEach { destination ->
val current = destination == selected
NavigationSuiteItem(
selected = current,
onClick = {
navController.replay(ShellNavigation.onTabSelected(route, destination))
},
// The label is right there beside it; announcing the glyph
// too would only say everything twice.
icon = { Icon(destination.icon(current), contentDescription = null) },
label = { Text(stringResource(destination.titleRes)) },
)
}
},
modifier = modifier.predictiveBack(
onBack = {
if (back is ShellBackAction.GoTo) {
navController.replay(ShellNavigation.onTabSelected(route, back.destination))
}
},
// On Alarms the handler stands aside, so the gesture previews
// *leaving the app* — which is what it is about to do.
enabled = back is ShellBackAction.GoTo,
),
) {
Box(Modifier.fillMaxSize()) {
CompositionLocalProvider(
LocalLivePillInset provides if (pill != null) ShellDefaults.LivePillBand else 0.dp,
) {
NavHost(
navController = navController,
startDestination = ShellNavigation.start.route,
enterTransition = { fade.targetContentEnter },
exitTransition = { fade.initialContentExit },
popEnterTransition = { fade.targetContentEnter },
popExitTransition = { fade.initialContentExit },
) {
composable(ClockulaDestination.ALARMS.route) { AlarmsScreen() }
composable(ClockulaDestination.TIMERS.route) { TimersScreen() }
composable(ClockulaDestination.STOPWATCH.route) { StopwatchScreen() }
composable(ClockulaDestination.WORLD_CLOCK.route) { WorldClockScreen() }
}
}
// In the pill's own band above the navigation component, not in the
// scaffold's primary-action slot: a collapsed wide rail is ~96dp
// across and cannot hold `1:23:45 ⏸ ⏹`. `windowInsetsPadding`
// honours what the nav suite already consumed, so this adds nothing
// in compact and clears the gesture bar on the rail layout.
LivePill(
state = pill,
onPrimaryAction = viewModel::onPillPrimaryAction,
onStop = viewModel::onPillStop,
modifier = Modifier
.align(Alignment.BottomCenter)
.windowInsetsPadding(
WindowInsets.safeDrawing.only(
WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal,
),
)
.padding(bottom = ShellDefaults.LivePillMargin),
)
}
}
}
/** Replays a [TabNavCommand] — the policy's decision — onto the controller. */
private fun NavHostController.replay(command: TabNavCommand) {
navigate(command.route) {
popUpTo(command.popUpTo) {
inclusive = command.popUpToInclusive
saveState = command.saveState
}
launchSingleTop = command.launchSingleTop
restoreState = command.restoreState
}
}
@@ -0,0 +1,191 @@
package de.jeanlucmakiola.clockula.ui.shell
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.HourglassEmpty
import androidx.compose.material.icons.rounded.Pause
import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.material.icons.rounded.Stop
import androidx.compose.material.icons.rounded.Timer
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.contentColorFor
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.graphics.TransformOrigin
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.format.ClockFormat
import de.jeanlucmakiola.clockula.domain.live.LivePillMode
import de.jeanlucmakiola.clockula.domain.live.LivePillState
import de.jeanlucmakiola.clockula.domain.live.LivePillSubject
import de.jeanlucmakiola.floret.identity.animateContentSizeMotion
/**
* The running-state surface, reachable from every tab (docs/PLAN.md §9): what is
* counting, how far it has got, and the two controls — pause/resume and stop —
* that otherwise mean going to find the row that owns it. App-local by design:
* a clock's running state is not a shape the family shares.
*
* Enters and leaves like the kit's `SnackChip`, so the two pills in the family
* move alike, and eases its width when `9:59` becomes `10:00`.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
internal fun LivePill(
state: LivePillState?,
onPrimaryAction: (LivePillState) -> Unit,
onStop: (LivePillState) -> Unit,
modifier: Modifier = Modifier,
) {
// The pill springs *out* as well as in, so the state it was showing has to
// outlive the null that removed it — otherwise it would blink to nothing
// and then animate an empty pill away.
var shown by remember { mutableStateOf<LivePillState?>(null) }
if (state != null) shown = state
val spatial = MaterialTheme.motionScheme.fastSpatialSpec<Float>()
val effects = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
val origin = TransformOrigin(0.5f, 1f)
AnimatedVisibility(
visible = state != null,
enter = scaleIn(spatial, initialScale = 0.8f, transformOrigin = origin) + fadeIn(effects),
exit = scaleOut(effects, targetScale = 0.9f, transformOrigin = origin) + fadeOut(effects),
modifier = modifier,
) {
// Latched state, live callbacks: for the whole exit animation the body
// still draws (and still takes taps) the subject that has just gone
// away, so acting on it would pause or restart something the user
// already stopped. `state == null` is the moment that stops.
shown?.let { PillBody(it, state != null, onPrimaryAction, onStop) }
}
}
@Composable
private fun PillBody(
state: LivePillState,
/** False once the pill is on its way out: its subject is no longer current. */
live: Boolean,
onPrimaryAction: (LivePillState) -> Unit,
onStop: (LivePillState) -> Unit,
) {
// A finished timer is asking for something; a running one is only
// reporting. The tertiary container is the tonal step that says so.
val color = if (state.mode == LivePillMode.EXPIRED) {
MaterialTheme.colorScheme.tertiaryContainer
} else {
MaterialTheme.colorScheme.surfaceContainerHighest
}
Surface(
color = color,
contentColor = contentColorFor(color),
shape = RoundedCornerShape(50),
shadowElevation = 6.dp,
modifier = Modifier.height(ShellDefaults.LivePillHeight).animateContentSizeMotion(),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(start = 20.dp, end = 8.dp),
) {
// Beside a visible readout, the glyph is decoration: announcing it
// would only put a noun in front of every reading.
Icon(state.subject.icon(), contentDescription = null)
val spoken = state.spokenValue()
Text(
text = state.readout(),
style = MaterialTheme.typography.titleMedium,
// Not a live region: at 1 Hz that would recite the countdown
// for as long as it runs. A full sentence when focused, and
// silence otherwise.
modifier = Modifier.clearAndSetSemantics { contentDescription = spoken },
)
if (state.otherActiveTimers > 0) {
Text(
text = pluralStringResource(
R.plurals.live_pill_more_timers,
state.otherActiveTimers,
state.otherActiveTimers,
),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (state.canPause) {
val resume = state.mode == LivePillMode.PAUSED
IconButton(onClick = { if (live) onPrimaryAction(state) }) {
Icon(
imageVector = if (resume) Icons.Rounded.PlayArrow else Icons.Rounded.Pause,
contentDescription = stringResource(
if (resume) R.string.live_pill_resume else R.string.live_pill_pause,
),
)
}
}
IconButton(onClick = { if (live) onStop(state) }) {
Icon(
imageVector = Icons.Rounded.Stop,
contentDescription = stringResource(R.string.live_pill_stop),
)
}
}
}
}
/** Countdowns round up, elapsed truncates — the pill never reads ahead of reality. */
@Composable
private fun LivePillState.readout(): String = when (subject) {
is LivePillSubject.TimerSubject -> ClockFormat.countdown(value)
LivePillSubject.StopwatchSubject -> ClockFormat.elapsed(value)
}
/** The whole sentence a screen reader says when the readout takes focus (D26). */
@Composable
private fun LivePillState.spokenValue(): String {
val reading = readout()
return when (val subject = subject) {
is LivePillSubject.TimerSubject -> {
val label = subject.label.ifBlank { stringResource(R.string.live_pill_timer_untitled) }
when (mode) {
LivePillMode.RUNNING ->
stringResource(R.string.live_pill_timer_running, label, reading)
LivePillMode.PAUSED ->
stringResource(R.string.live_pill_timer_paused, label, reading)
LivePillMode.EXPIRED ->
stringResource(R.string.live_pill_timer_expired, label)
}
}
LivePillSubject.StopwatchSubject -> when (mode) {
LivePillMode.RUNNING -> stringResource(R.string.live_pill_stopwatch_running, reading)
else -> stringResource(R.string.live_pill_stopwatch_paused, reading)
}
}
}
private fun LivePillSubject.icon(): ImageVector = when (this) {
is LivePillSubject.TimerSubject -> Icons.Rounded.HourglassEmpty
LivePillSubject.StopwatchSubject -> Icons.Rounded.Timer
}
@@ -0,0 +1,33 @@
package de.jeanlucmakiola.clockula.ui.shell
import de.jeanlucmakiola.clockula.data.stopwatch.StopwatchRepository
import de.jeanlucmakiola.clockula.data.timers.TimerRepository
import de.jeanlucmakiola.clockula.domain.live.LivePillSelector
import de.jeanlucmakiola.clockula.domain.live.LivePillState
import de.jeanlucmakiola.clockula.domain.time.ElapsedRealtimeClock
import de.jeanlucmakiola.clockula.domain.time.Ticker
import de.jeanlucmakiola.clockula.domain.time.WallClock
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import javax.inject.Inject
import javax.inject.Singleton
/** The two repositories and the ticker, combined into one pill state. */
@Singleton
internal class LivePillSource @Inject constructor(
private val timers: TimerRepository,
private val stopwatch: StopwatchRepository,
private val elapsed: ElapsedRealtimeClock,
private val wall: WallClock,
private val ticker: Ticker,
) {
/** Null when nothing is active. Distinct-until-changed. */
fun state(): Flow<LivePillState?> =
// Three inputs, one answer: a repository write lands at once, and the
// seconds counting down cost nothing but a tick. The clocks are read
// inside the combine, so the value is always resolved *now*.
combine(timers.timers(), stopwatch.run(), ticker.ticks(ShellDefaults.PillTick)) { rows, run, _ ->
LivePillSelector.select(rows, run, elapsed.elapsedRealtime(), wall.now())
}.distinctUntilChanged()
}
@@ -0,0 +1,101 @@
package de.jeanlucmakiola.clockula.ui.shell
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
import kotlinx.coroutines.launch
/** What the one-shot ask should do, given what is known. Pure, so it is testable. */
internal enum class NotificationAsk {
/** The flag has not been read yet, or the ask has already happened. Do nothing. */
NOTHING,
/** Already granted: record the ask without putting a dialog in front of anyone. */
MARK_ONLY,
/** Put the request up. The flag is written from the *result*, never before it. */
REQUEST,
}
/**
* The decision behind [RequestNotificationPermissionOnce].
*
* The one rule that matters: when a request is due the answer is [REQUEST] and
* *only* [REQUEST] — marking the preference is what the result callback does.
* Writing the flag first would flip `asked`, cancel the effect that was about to
* launch the dialog, and leave an install recorded as "asked" that was never
* asked, with alarms silent and no second chance.
*/
internal fun notificationAskDecision(
asked: Boolean,
alreadyRequested: Boolean,
granted: Boolean,
): NotificationAsk = when {
asked || alreadyRequested -> NotificationAsk.NOTHING
granted -> NotificationAsk.MARK_ONLY
else -> NotificationAsk.REQUEST
}
/**
* Asks for POST_NOTIFICATIONS once per install, on API 33+. No-op below.
*
* Gated on a preference of our own rather than on the platform's two-strike
* suppression, so "we have asked" is a recorded fact instead of a guess. A
* denial is *not* explained here: the screen with room to say why an alarm
* might stay silent is M10's self-check, and that is where the exact-alarm and
* full-screen-intent grant pages are linked from too.
*
* The order is deliberate: launch, then record. The preference is the effect's
* key, so writing it before the launch races the recomposition that cancels the
* effect — and losing that race means the user is never prompted at all. If the
* process dies between the dialog appearing and the answer arriving, the flag
* stays false and the ask is simply due again, which is the failure worth
* having.
*/
@Composable
internal fun RequestNotificationPermissionOnce(prefs: SettingsPrefs) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
val context = LocalContext.current
val scope = rememberCoroutineScope()
// True until the flag is read: never ask on the strength of a default.
val asked by prefs.notificationPermissionAsked.collectAsStateWithLifecycle(initialValue = true)
// Survives a rotation, so a configuration change while the system dialog is
// up does not stack a second one behind it.
var requested by rememberSaveable { mutableStateOf(false) }
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) {
// Either way the flag is set: one ask, whatever the answer. Written
// here, from the result, so it can only ever record a real ask.
scope.launch { prefs.markNotificationPermissionAsked() }
}
LaunchedEffect(asked) {
val granted = ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS,
) == PackageManager.PERMISSION_GRANTED
when (notificationAskDecision(asked, requested, granted)) {
NotificationAsk.NOTHING -> Unit
NotificationAsk.MARK_ONLY -> prefs.markNotificationPermissionAsked()
NotificationAsk.REQUEST -> {
requested = true
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}
}
@@ -0,0 +1,75 @@
package de.jeanlucmakiola.clockula.ui.shell
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedSurface
import de.jeanlucmakiola.floret.components.Position
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/** The pill's measurements, and the cadence its readout advances on. */
object ShellDefaults {
val LivePillHeight: Dp = 56.dp
val LivePillMargin: Dp = 16.dp
/** Height + margin — what tab content keeps clear while the pill is up. */
val LivePillBand: Dp = LivePillHeight + LivePillMargin
/**
* One second: the pill formats to whole seconds, so anything faster is
* recomposition nobody can see. The stopwatch tab's centisecond readout is
* M7's, and will ask the ticker for its own period.
*/
val PillTick: Duration = 1.seconds
}
/**
* Extra bottom inset a tab's content should add. Zero when no pill is showing.
*
* `compositionLocalOf`, not the static form: the value changes whenever the
* pill comes and goes, and a static local would recompose the whole subtree.
*/
val LocalLivePillInset: ProvidableCompositionLocal<Dp> = compositionLocalOf { 0.dp }
/**
* A top-level tab's title bar over a single sentence on a grouped surface —
* the shape M4's four tabs share while their content belongs to M5–M8. Each
* tab keeps the pill's band clear through [LocalLivePillInset], so a countdown
* never sits on top of the last row.
*
* Lives here rather than being written out four times: the tabs differ only in
* two strings today, and each of M5–M8 replaces its own tab outright.
*/
@Composable
internal fun EmptyTabScreen(
title: String,
message: String,
modifier: Modifier = Modifier,
) {
CollapsingScaffold(title = title, modifier = modifier) {
GroupedSurface(
position = Position.Alone,
modifier = Modifier.padding(horizontal = GroupedListInset),
) {
Text(
text = message,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 20.dp, vertical = 24.dp),
)
}
Spacer(Modifier.height(LocalLivePillInset.current))
}
}
@@ -0,0 +1,25 @@
package de.jeanlucmakiola.clockula.ui.shell
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Alarm
import androidx.compose.material.icons.outlined.HourglassEmpty
import androidx.compose.material.icons.outlined.Public
import androidx.compose.material.icons.outlined.Timer
import androidx.compose.material.icons.rounded.Alarm
import androidx.compose.material.icons.rounded.HourglassEmpty
import androidx.compose.material.icons.rounded.Public
import androidx.compose.material.icons.rounded.Timer
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.ImageVector
/** The navigation item's icon, filled when selected and outlined when not. */
@Composable
internal fun ClockulaDestination.icon(selected: Boolean): ImageVector = when (this) {
// The hourglass is the countdown and the stopwatch face is the count-up:
// two clocks that would otherwise wear the same glyph in the same bar.
ClockulaDestination.ALARMS -> if (selected) Icons.Rounded.Alarm else Icons.Outlined.Alarm
ClockulaDestination.TIMERS ->
if (selected) Icons.Rounded.HourglassEmpty else Icons.Outlined.HourglassEmpty
ClockulaDestination.STOPWATCH -> if (selected) Icons.Rounded.Timer else Icons.Outlined.Timer
ClockulaDestination.WORLD_CLOCK -> if (selected) Icons.Rounded.Public else Icons.Outlined.Public
}
@@ -0,0 +1,57 @@
package de.jeanlucmakiola.clockula.ui.shell
/** What a tab tap does to the back stack. Pure data; the composable replays it. */
data class TabNavCommand(
val route: String,
val popUpTo: String,
val popUpToInclusive: Boolean,
val saveState: Boolean,
val restoreState: Boolean,
val launchSingleTop: Boolean,
)
sealed interface ShellBackAction {
data class GoTo(val destination: ClockulaDestination) : ShellBackAction
/** Not ours — let the dispatcher continue, which leaves the app. */
data object Exit : ShellBackAction
}
/** The shell's navigation policy, as pure functions over routes. */
object ShellNavigation {
val start: ClockulaDestination = ClockulaDestination.ALARMS
val topLevel: List<ClockulaDestination> = ClockulaDestination.entries.toList()
private val byRoute: Map<String, ClockulaDestination> = topLevel.associateBy { it.route }
/** Exact match only: a nested route is not a tab. */
fun destinationOf(route: String?): ClockulaDestination? = byRoute[route]
/**
* The canonical multi-back-stack command. Re-selecting the tab the user is
* already on does *not* restore its saved stack, which pops that tab back
* to its own root — the standard behaviour, and the contract M5's editor
* will rely on.
*/
fun onTabSelected(currentRoute: String?, target: ClockulaDestination): TabNavCommand =
TabNavCommand(
route = target.route,
popUpTo = start.route,
popUpToInclusive = false,
saveState = true,
restoreState = currentRoute != target.route,
launchSingleTop = true,
)
/**
* Alarms is the shell's root: back from any other tab returns to it, and
* back from Alarms leaves the app. An unrecognised route is a nested
* destination the `NavController` pops itself, so the shell stands aside.
*/
fun onBack(currentRoute: String?): ShellBackAction = when (destinationOf(currentRoute)) {
null, start -> ShellBackAction.Exit
else -> ShellBackAction.GoTo(start)
}
}
@@ -0,0 +1,59 @@
package de.jeanlucmakiola.clockula.ui.shell
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.clockula.data.stopwatch.StopwatchRepository
import de.jeanlucmakiola.clockula.data.timers.TimerRepository
import de.jeanlucmakiola.clockula.domain.live.LivePillMode
import de.jeanlucmakiola.clockula.domain.live.LivePillState
import de.jeanlucmakiola.clockula.domain.live.LivePillSubject
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
/** The pill's state and its two actions, shared by every tab. */
@HiltViewModel
class ShellViewModel @Inject internal constructor(
source: LivePillSource,
private val timers: TimerRepository,
private val stopwatch: StopwatchRepository,
) : ViewModel() {
val livePill: StateFlow<LivePillState?> = source.state()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS), null)
/** Pause a running subject, resume a paused one. No-op when [LivePillState.canPause] is false. */
fun onPillPrimaryAction(state: LivePillState) {
if (!state.canPause) return
val resume = state.mode == LivePillMode.PAUSED
viewModelScope.launch {
when (val subject = state.subject) {
is LivePillSubject.TimerSubject ->
if (resume) timers.start(subject.timerId) else timers.pause(subject.timerId)
LivePillSubject.StopwatchSubject ->
if (resume) stopwatch.start() else stopwatch.pause()
}
}
}
/** Stop: `reset()`, never `delete()`. */
fun onPillStop(state: LivePillState) {
viewModelScope.launch {
when (val subject = state.subject) {
// Reset, not delete: the pill must not be able to destroy a
// timer the user configured and will want again.
is LivePillSubject.TimerSubject -> timers.reset(subject.timerId)
LivePillSubject.StopwatchSubject -> stopwatch.reset()
}
}
}
private companion object {
/** Long enough to survive a rotation without re-subscribing the repositories. */
const val STOP_TIMEOUT_MILLIS = 5_000L
}
}
@@ -0,0 +1,51 @@
package de.jeanlucmakiola.clockula.ui.stopwatch
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.format.ClockFormat
import de.jeanlucmakiola.clockula.ui.shell.LocalLivePillInset
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedSurface
import de.jeanlucmakiola.floret.components.Position
import kotlin.time.Duration
/**
* A top-level tab: its title bar and the readout, standing at zero. Starting,
* lapping and the readout's final typography are M7's — which is why the
* figure here is the same [ClockFormat] a running stopwatch will print.
*/
@Composable
fun StopwatchScreen(modifier: Modifier = Modifier) {
CollapsingScaffold(title = stringResource(R.string.tab_stopwatch), modifier = modifier) {
GroupedSurface(
position = Position.Alone,
modifier = Modifier.padding(horizontal = GroupedListInset),
) {
Column(
modifier = Modifier.fillMaxWidth().padding(vertical = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = ClockFormat.elapsed(Duration.ZERO),
style = MaterialTheme.typography.displayLarge,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
Spacer(Modifier.height(LocalLivePillInset.current))
}
}
@@ -0,0 +1,17 @@
package de.jeanlucmakiola.clockula.ui.timers
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.ui.shell.EmptyTabScreen
/** A top-level tab: its title bar and its empty state. Creating a timer is M6's. */
@Composable
fun TimersScreen(modifier: Modifier = Modifier) {
EmptyTabScreen(
title = stringResource(R.string.tab_timers),
message = stringResource(R.string.timers_empty),
modifier = modifier,
)
}
@@ -0,0 +1,17 @@
package de.jeanlucmakiola.clockula.ui.worldclock
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.ui.shell.EmptyTabScreen
/** A top-level tab: its title bar and its empty state. The zone list is M8's. */
@Composable
fun WorldClockScreen(modifier: Modifier = Modifier) {
EmptyTabScreen(
title = stringResource(R.string.tab_world_clock),
message = stringResource(R.string.world_clocks_empty),
modifier = modifier,
)
}
+34
View File
@@ -15,4 +15,38 @@
<!-- Crash reporting (floret-kit core-crash) -->
<string name="crash_report_issue_title">Crash report</string>
<string name="report_issue_url" translatable="false">https://codeberg.org/jlmakiola/clockula/issues/new</string>
<!-- M4: the app shell's four tabs -->
<string name="tab_alarms">Alarms</string>
<string name="tab_timers">Timers</string>
<string name="tab_stopwatch">Stopwatch</string>
<string name="tab_world_clock">World clock</string>
<!-- M4: the tabs' empty states -->
<string name="alarms_empty">No alarms yet</string>
<string name="timers_empty">No timers yet</string>
<string name="world_clocks_empty">No world clocks yet</string>
<!-- M4: the live pill -->
<string name="live_pill_pause">Pause</string>
<string name="live_pill_resume">Resume</string>
<string name="live_pill_stop">Stop</string>
<plurals name="live_pill_more_timers">
<item quantity="one">+%1$d more</item>
<item quantity="other">+%1$d more</item>
</plurals>
<string name="live_pill_timer_running">Timer %1$s, %2$s remaining</string>
<string name="live_pill_timer_paused">Timer %1$s paused, %2$s remaining</string>
<string name="live_pill_timer_expired">Timer %1$s finished</string>
<string name="live_pill_timer_untitled">Timer</string>
<string name="live_pill_stopwatch_running">Stopwatch running, %1$s</string>
<string name="live_pill_stopwatch_paused">Stopwatch paused at %1$s</string>
<!-- M4: the ring screen's dismiss challenge -->
<string name="ring_challenge_math_prompt">What is %1$s?</string>
<string name="ring_challenge_math_hint">Answer</string>
<string name="ring_challenge_wrong">Not quite \u2014 here is another</string>
<string name="ring_challenge_hold">Hold to dismiss</string>
<string name="ring_challenge_escape">Dismiss without solving</string>
<string name="ring_snooze_unavailable">No snoozes left</string>
</resources>