From 83c5e9fe3fef86d851f43385a0fe3c5a8a5b334c Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 12 Sep 2026 13:07:18 +0200 Subject: [PATCH] feat(shell): the navigation host, four tabs and the live pill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) Claude-Session: https://claude.ai/code/session_01Wmy1BpCKi8KeSjaWhYuCPV --- .../clockula/ui/LivePillInstrumentedTest.kt | 89 ++++++++ .../clockula/ui/ShellInstrumentedTest.kt | 80 ++++++++ .../jeanlucmakiola/clockula/MainActivity.kt | 39 +--- .../clockula/ui/alarms/AlarmsScreen.kt | 17 ++ .../clockula/ui/shell/ClockulaDestination.kt | 12 ++ .../clockula/ui/shell/ClockulaShell.kt | 141 +++++++++++++ .../clockula/ui/shell/LivePill.kt | 191 ++++++++++++++++++ .../clockula/ui/shell/LivePillSource.kt | 33 +++ .../ui/shell/NotificationPermission.kt | 101 +++++++++ .../clockula/ui/shell/ShellDefaults.kt | 75 +++++++ .../clockula/ui/shell/ShellIcons.kt | 25 +++ .../clockula/ui/shell/ShellNavigation.kt | 57 ++++++ .../clockula/ui/shell/ShellViewModel.kt | 59 ++++++ .../clockula/ui/stopwatch/StopwatchScreen.kt | 51 +++++ .../clockula/ui/timers/TimersScreen.kt | 17 ++ .../ui/worldclock/WorldClockScreen.kt | 17 ++ app/src/main/res/values/strings.xml | 34 ++++ .../clockula/ui/shell/LivePillSourceTest.kt | 181 +++++++++++++++++ .../ui/shell/NotificationPermissionTest.kt | 49 +++++ .../clockula/ui/shell/ShellNavigationTest.kt | 141 +++++++++++++ .../clockula/ui/shell/ShellViewModelTest.kt | 163 +++++++++++++++ 21 files changed, 1544 insertions(+), 28 deletions(-) create mode 100644 app/src/androidTest/java/de/jeanlucmakiola/clockula/ui/LivePillInstrumentedTest.kt create mode 100644 app/src/androidTest/java/de/jeanlucmakiola/clockula/ui/ShellInstrumentedTest.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsScreen.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ClockulaDestination.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ClockulaShell.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/LivePill.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/LivePillSource.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/NotificationPermission.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellDefaults.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellIcons.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigation.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellViewModel.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/stopwatch/StopwatchScreen.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersScreen.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockScreen.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/LivePillSourceTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/NotificationPermissionTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigationTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellViewModelTest.kt diff --git a/app/src/androidTest/java/de/jeanlucmakiola/clockula/ui/LivePillInstrumentedTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/clockula/ui/LivePillInstrumentedTest.kt new file mode 100644 index 0000000..708a98a --- /dev/null +++ b/app/src/androidTest/java/de/jeanlucmakiola/clockula/ui/LivePillInstrumentedTest.kt @@ -0,0 +1,89 @@ +package de.jeanlucmakiola.clockula.ui + +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onAllNodesWithContentDescription +import androidx.compose.ui.test.onFirst +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.clockula.R +import de.jeanlucmakiola.clockula.domain.live.LivePillMode +import de.jeanlucmakiola.clockula.domain.live.LivePillState +import de.jeanlucmakiola.clockula.domain.live.LivePillSubject +import de.jeanlucmakiola.clockula.ui.shell.LivePill +import de.jeanlucmakiola.clockula.ui.theme.ClockulaTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.time.Duration.Companion.minutes + +/** + * The pill's exit animation, which only a real composition can show: the state + * the pill was showing is latched so the spring has something to animate away, + * and for those few hundred milliseconds the body is still laid out and still + * takes taps. A second tap in that window must not reach the subject that has + * already gone — `timers.start(id)` on a timer the user just stopped restarts + * it from its full duration. + * + * Compiled in the gate, run on a device (slice plan D18). + */ +@RunWith(AndroidJUnit4::class) +class LivePillInstrumentedTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val running = LivePillState( + subject = LivePillSubject.TimerSubject(timerId = 7L, label = "Pasta"), + mode = LivePillMode.RUNNING, + value = 4.minutes, + otherActiveTimers = 0, + ) + + private fun string(id: Int): String = + InstrumentationRegistry.getInstrumentation().targetContext.getString(id) + + @Test + fun aTapWhileThePillIsLeavingDoesNotReachTheOldSubject() { + var stops = 0 + var primaryActions = 0 + val state = mutableStateOf(running) + + composeTestRule.setContent { + ClockulaTheme { + LivePill( + state = state.value, + onPrimaryAction = { primaryActions++ }, + onStop = { stops++ }, + ) + } + } + + composeTestRule.onAllNodesWithContentDescription(string(R.string.live_pill_stop)) + .onFirst() + .performClick() + assertThat(stops).isEqualTo(1) + + // The subject is gone; the exit spring has not finished playing. + composeTestRule.mainClock.autoAdvance = false + composeTestRule.runOnUiThread { state.value = null } + composeTestRule.mainClock.advanceTimeByFrame() + + tapIfPresent(string(R.string.live_pill_stop)) + tapIfPresent(string(R.string.live_pill_pause)) + + assertThat(listOf(stops, primaryActions)).containsExactly(1, 0).inOrder() + } + + /** + * Whether the body has already left the composition or is still springing + * out, the promise is the same: nothing more happens. So the tap is made + * only if there is something there to tap. + */ + private fun tapIfPresent(contentDescription: String) { + val nodes = composeTestRule.onAllNodesWithContentDescription(contentDescription) + if (nodes.fetchSemanticsNodes().isNotEmpty()) nodes.onFirst().performClick() + } +} diff --git a/app/src/androidTest/java/de/jeanlucmakiola/clockula/ui/ShellInstrumentedTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/clockula/ui/ShellInstrumentedTest.kt new file mode 100644 index 0000000..4d0fd00 --- /dev/null +++ b/app/src/androidTest/java/de/jeanlucmakiola/clockula/ui/ShellInstrumentedTest.kt @@ -0,0 +1,80 @@ +package de.jeanlucmakiola.clockula.ui + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.lifecycle.Lifecycle +import androidx.test.espresso.Espresso +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.clockula.MainActivity +import de.jeanlucmakiola.clockula.R +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * What only a device can answer about the shell: that the four tabs really + * compose, that a real system back press goes where the policy says, and that + * the selected tab survives an activity recreation. + * + * Compiled in the gate, run on a device (slice plan D18). + */ +@RunWith(AndroidJUnit4::class) +class ShellInstrumentedTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + private fun string(id: Int): String = + InstrumentationRegistry.getInstrumentation().targetContext.getString(id) + + @Test + fun allFourTabsAreDisplayed() { + val labels = listOf( + R.string.tab_alarms, + R.string.tab_timers, + R.string.tab_stopwatch, + R.string.tab_world_clock, + ).map(::string) + + labels.forEach { composeTestRule.onNodeWithText(it).assertIsDisplayed() } + } + + @Test + fun tappingATabSwapsTheContent() { + composeTestRule.onNodeWithText(string(R.string.tab_timers)).performClick() + + composeTestRule.onNodeWithText(string(R.string.timers_empty)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.alarms_empty)).assertDoesNotExist() + } + + @Test + fun backFromANonStartTabReturnsToAlarms() { + composeTestRule.onNodeWithText(string(R.string.tab_stopwatch)).performClick() + + Espresso.pressBack() + + composeTestRule.onNodeWithText(string(R.string.alarms_empty)).assertIsDisplayed() + assertThat(composeTestRule.activity.isFinishing).isFalse() + } + + @Test + fun backFromAlarmsLeavesTheApp() { + Espresso.pressBackUnconditionally() + + composeTestRule.activityRule.scenario.onActivity { } + assertThat(composeTestRule.activityRule.scenario.state).isEqualTo(Lifecycle.State.DESTROYED) + } + + @Test + fun theSelectedTabSurvivesRecreation() { + composeTestRule.onNodeWithText(string(R.string.tab_world_clock)).performClick() + + composeTestRule.activityRule.scenario.recreate() + + composeTestRule.onNodeWithText(string(R.string.world_clocks_empty)).assertIsDisplayed() + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/MainActivity.kt b/app/src/main/java/de/jeanlucmakiola/clockula/MainActivity.kt index f0ff864..842e2f4 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/MainActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/MainActivity.kt @@ -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) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsScreen.kt new file mode 100644 index 0000000..1b4feba --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsScreen.kt @@ -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, + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ClockulaDestination.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ClockulaDestination.kt new file mode 100644 index 0000000..0b86314 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ClockulaDestination.kt @@ -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), +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ClockulaShell.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ClockulaShell.kt new file mode 100644 index 0000000..327bf89 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ClockulaShell.kt @@ -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 + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/LivePill.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/LivePill.kt new file mode 100644 index 0000000..2d995c2 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/LivePill.kt @@ -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(null) } + if (state != null) shown = state + + val spatial = MaterialTheme.motionScheme.fastSpatialSpec() + val effects = MaterialTheme.motionScheme.fastEffectsSpec() + 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 +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/LivePillSource.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/LivePillSource.kt new file mode 100644 index 0000000..24efcf2 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/LivePillSource.kt @@ -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 = + // 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() +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/NotificationPermission.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/NotificationPermission.kt new file mode 100644 index 0000000..4908a39 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/NotificationPermission.kt @@ -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) + } + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellDefaults.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellDefaults.kt new file mode 100644 index 0000000..d81c665 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellDefaults.kt @@ -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 = 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)) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellIcons.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellIcons.kt new file mode 100644 index 0000000..daf9ba2 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellIcons.kt @@ -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 +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigation.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigation.kt new file mode 100644 index 0000000..ed9b611 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigation.kt @@ -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.entries.toList() + + private val byRoute: Map = 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) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellViewModel.kt new file mode 100644 index 0000000..adfd4fe --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ShellViewModel.kt @@ -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 = 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 + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/stopwatch/StopwatchScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/stopwatch/StopwatchScreen.kt new file mode 100644 index 0000000..b5eccbc --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/stopwatch/StopwatchScreen.kt @@ -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)) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersScreen.kt new file mode 100644 index 0000000..a4e6bf8 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/timers/TimersScreen.kt @@ -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, + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockScreen.kt new file mode 100644 index 0000000..7f89607 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/worldclock/WorldClockScreen.kt @@ -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, + ) +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5281849..2b65dbc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -15,4 +15,38 @@ Crash report https://codeberg.org/jlmakiola/clockula/issues/new + + + Alarms + Timers + Stopwatch + World clock + + + No alarms yet + No timers yet + No world clocks yet + + + Pause + Resume + Stop + + +%1$d more + +%1$d more + + Timer %1$s, %2$s remaining + Timer %1$s paused, %2$s remaining + Timer %1$s finished + Timer + Stopwatch running, %1$s + Stopwatch paused at %1$s + + + What is %1$s? + Answer + Not quite \u2014 here is another + Hold to dismiss + Dismiss without solving + No snoozes left diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/LivePillSourceTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/LivePillSourceTest.kt new file mode 100644 index 0000000..2953515 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/LivePillSourceTest.kt @@ -0,0 +1,181 @@ +package de.jeanlucmakiola.clockula.ui.shell + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.clockula.domain.StopwatchRun +import de.jeanlucmakiola.clockula.domain.StopwatchState +import de.jeanlucmakiola.clockula.domain.Timer +import de.jeanlucmakiola.clockula.domain.TimerState +import de.jeanlucmakiola.clockula.domain.live.LivePillMode +import de.jeanlucmakiola.clockula.domain.live.LivePillSubject +import de.jeanlucmakiola.clockula.testing.BERLIN +import de.jeanlucmakiola.clockula.testing.FakeElapsedRealtimeClock +import de.jeanlucmakiola.clockula.testing.FakeStopwatchRepository +import de.jeanlucmakiola.clockula.testing.FakeTicker +import de.jeanlucmakiola.clockula.testing.FakeTimerRepository +import de.jeanlucmakiola.clockula.testing.FakeWallClock +import de.jeanlucmakiola.clockula.testing.instantAt +import de.jeanlucmakiola.clockula.testing.runningTimer +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +/** + * The pill's wiring: two repositories and one injected ticker, combined into a + * single distinct-until-changed stream. A repository write lands at once; the + * seconds ticking down cost nothing but a tick. + */ +class LivePillSourceTest { + + private val now = 1.hours + private val wallInstant = instantAt(BERLIN, "2026-06-10T12:00") + + private val timers = FakeTimerRepository() + private val stopwatch = FakeStopwatchRepository() + private val elapsed = FakeElapsedRealtimeClock(now) + private val wall = FakeWallClock(wallInstant) + private val ticker = FakeTicker() + + private val source = LivePillSource(timers, stopwatch, elapsed, wall, ticker) + + private fun running(id: Long = 7L, label: String = "Pasta", left: Duration): Timer = + runningTimer().copy( + id = id, + label = label, + state = TimerState.RUNNING, + remaining = left, + startedAtElapsedRealtime = Duration.ZERO, + endsAtElapsedRealtime = now + left, + endsAtWallClock = wallInstant + left, + sortOrder = 0, + ) + + private fun pausedForm(timer: Timer): Timer = timer.copy( + state = TimerState.PAUSED, + startedAtElapsedRealtime = null, + endsAtElapsedRealtime = null, + endsAtWallClock = null, + ) + + @Test + fun `nothing active emits no pill`() = runTest { + source.state().test { + assertThat(awaitItem()).isNull() + cancel() + } + } + + @Test + fun `a timer appearing in the repository raises the pill`() = runTest { + source.state().test { + assertThat(awaitItem()).isNull() + + timers.set(listOf(running(left = 4.minutes))) + + assertThat(awaitItem()?.subject).isEqualTo(LivePillSubject.TimerSubject(7L, "Pasta")) + cancel() + } + } + + @Test + fun `the readout is driven at the shell's pill cadence`() = runTest { + source.state().test { + awaitItem() + + assertThat(ticker.requestedPeriods).containsExactly(ShellDefaults.PillTick) + cancel() + } + } + + @Test + fun `a tick advances the readout without a repository write`() = runTest { + timers.set(listOf(running(left = 3.minutes))) + + source.state().test { + assertThat(awaitItem()?.value).isEqualTo(3.minutes) + + elapsed.advance(60.seconds) + ticker.tick() + + assertThat(awaitItem()?.value).isEqualTo(2.minutes) + assertThat(timers.calls).isEmpty() + cancel() + } + } + + @Test + fun `a tick against a frozen clock emits nothing`() = runTest { + timers.set(listOf(running(left = 3.minutes))) + + source.state().test { + awaitItem() + + ticker.tick() + ticker.tick() + + expectNoEvents() + cancel() + } + } + + @Test + fun `pausing the timer reaches the pill without waiting for a tick`() = runTest { + val timer = running(left = 3.minutes) + timers.set(listOf(timer)) + + source.state().test { + assertThat(awaitItem()?.mode).isEqualTo(LivePillMode.RUNNING) + + timers.set(listOf(pausedForm(timer))) + + assertThat(awaitItem()?.mode).isEqualTo(LivePillMode.PAUSED) + cancel() + } + } + + @Test + fun `emptying the repository lowers the pill`() = runTest { + timers.set(listOf(running(left = 3.minutes))) + + source.state().test { + assertThat(awaitItem()).isNotNull() + + timers.set(emptyList()) + + assertThat(awaitItem()).isNull() + cancel() + } + } + + @Test + fun `a timer takes the pill over from a running stopwatch`() = runTest { + stopwatch.set( + StopwatchRun( + state = StopwatchState.RUNNING, + startedAtElapsedRealtime = now - 30.seconds, + ), + ) + + source.state().test { + assertThat(awaitItem()?.subject).isEqualTo(LivePillSubject.StopwatchSubject) + + timers.set(listOf(running(left = 3.minutes))) + + assertThat(awaitItem()?.subject).isEqualTo(LivePillSubject.TimerSubject(7L, "Pasta")) + cancel() + } + } + + @Test + fun `a fresh collector is never blank for a cadence`() = runTest { + timers.set(listOf(running(left = 3.minutes))) + + source.state().test { + assertThat(awaitItem()?.value).isEqualTo(3.minutes) + cancel() + } + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/NotificationPermissionTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/NotificationPermissionTest.kt new file mode 100644 index 0000000..374cbdc --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/NotificationPermissionTest.kt @@ -0,0 +1,49 @@ +package de.jeanlucmakiola.clockula.ui.shell + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +/** + * The one-shot notification ask, minus Compose: whether a request is due, and — + * the part that was a bug — that "record the ask" is never the answer when a + * dialog still has to be put up. The preference is the effect's key, so a write + * before the launch cancels the very effect that was about to ask, and the + * install ends up recorded as asked while the user was never prompted. + */ +class NotificationPermissionTest { + + @Test + fun `a request that is due asks, and does not record the ask first`() { + val decision = notificationAskDecision(asked = false, alreadyRequested = false, granted = false) + + assertThat(decision).isEqualTo(NotificationAsk.REQUEST) + } + + @Test + fun `an unread flag reads as asked, so nothing happens on a default`() { + val decision = notificationAskDecision(asked = true, alreadyRequested = false, granted = false) + + assertThat(decision).isEqualTo(NotificationAsk.NOTHING) + } + + @Test + fun `an already granted permission is recorded without a dialog`() { + val decision = notificationAskDecision(asked = false, alreadyRequested = false, granted = true) + + assertThat(decision).isEqualTo(NotificationAsk.MARK_ONLY) + } + + @Test + fun `a request already up is not put up twice`() { + val decision = notificationAskDecision(asked = false, alreadyRequested = true, granted = false) + + assertThat(decision).isEqualTo(NotificationAsk.NOTHING) + } + + @Test + fun `having asked once, a later grant changes nothing`() { + val decision = notificationAskDecision(asked = true, alreadyRequested = true, granted = true) + + assertThat(decision).isEqualTo(NotificationAsk.NOTHING) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigationTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigationTest.kt new file mode 100644 index 0000000..e38f7b2 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellNavigationTest.kt @@ -0,0 +1,141 @@ +package de.jeanlucmakiola.clockula.ui.shell + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +/** + * The shell's navigation policy as pure data: which tab is the root, what a tab + * tap does to the back stack, and where Back goes. The four lines that replay a + * [TabNavCommand] onto a `NavController` are not worth testing; this is. + */ +class ShellNavigationTest { + + @Test + fun `the four tabs are listed in tab order`() { + val tabs = ShellNavigation.topLevel + + assertThat(tabs).containsExactly( + ClockulaDestination.ALARMS, + ClockulaDestination.TIMERS, + ClockulaDestination.STOPWATCH, + ClockulaDestination.WORLD_CLOCK, + ).inOrder() + } + + @Test + fun `every tab has its own non-blank route`() { + val routes = ShellNavigation.topLevel.map { it.route } + + assertThat(routes.filter { it.isNotBlank() }.toSet()).hasSize(4) + } + + @Test + fun `every tab has its own title resource`() { + val titles = ShellNavigation.topLevel.map { it.titleRes } + + assertThat(titles.filter { it != 0 }.toSet()).hasSize(4) + } + + @Test + fun `alarms is the start destination`() { + assertThat(ShellNavigation.start).isEqualTo(ClockulaDestination.ALARMS) + } + + @Test + fun `each tab's own route resolves back to it`() { + val resolved = ClockulaDestination.entries.map { ShellNavigation.destinationOf(it.route) } + + assertThat(resolved).isEqualTo(ClockulaDestination.entries.toList()) + } + + @Test + fun `a null route resolves to no destination`() { + assertThat(ShellNavigation.destinationOf(null)).isNull() + } + + @Test + fun `an unknown route resolves to no destination`() { + assertThat(ShellNavigation.destinationOf("nope")).isNull() + } + + @Test + fun `a nested route does not resolve by prefix`() { + assertThat(ShellNavigation.destinationOf("alarms/12")).isNull() + } + + @Test + fun `selecting another tab saves and restores the tab's own stack`() { + val command = ShellNavigation.onTabSelected("alarms", ClockulaDestination.TIMERS) + + assertThat(command).isEqualTo( + TabNavCommand( + route = "timers", + popUpTo = "alarms", + popUpToInclusive = false, + saveState = true, + restoreState = true, + launchSingleTop = true, + ), + ) + } + + @Test + fun `re-selecting the current tab pops its own stack instead of restoring it`() { + val command = ShellNavigation.onTabSelected("timers", ClockulaDestination.TIMERS) + + assertThat(command).isEqualTo( + TabNavCommand( + route = "timers", + popUpTo = "alarms", + popUpToInclusive = false, + saveState = true, + restoreState = false, + launchSingleTop = true, + ), + ) + } + + @Test + fun `selecting a tab before the first destination is known restores its stack`() { + val command = ShellNavigation.onTabSelected(null, ClockulaDestination.TIMERS) + + assertThat(command.restoreState).isTrue() + } + + @Test + fun `every tab tap pops up to the start destination`() { + val popUpTos = ClockulaDestination.entries.flatMap { current -> + ClockulaDestination.entries.map { target -> + ShellNavigation.onTabSelected(current.route, target).popUpTo + } + } + + assertThat(popUpTos.toSet()).containsExactly(ShellNavigation.start.route) + } + + @Test + fun `back from alarms leaves the app`() { + assertThat(ShellNavigation.onBack("alarms")).isEqualTo(ShellBackAction.Exit) + } + + @Test + fun `back from any other tab returns to alarms`() { + val actions = listOf("timers", "stopwatch", "world_clock").map(ShellNavigation::onBack) + + assertThat(actions).containsExactly( + ShellBackAction.GoTo(ClockulaDestination.ALARMS), + ShellBackAction.GoTo(ClockulaDestination.ALARMS), + ShellBackAction.GoTo(ClockulaDestination.ALARMS), + ) + } + + @Test + fun `back before the first destination is known leaves the app`() { + assertThat(ShellNavigation.onBack(null)).isEqualTo(ShellBackAction.Exit) + } + + @Test + fun `back from a nested destination is not the shell's to intercept`() { + assertThat(ShellNavigation.onBack("alarms/12")).isEqualTo(ShellBackAction.Exit) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellViewModelTest.kt new file mode 100644 index 0000000..6c351da --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/shell/ShellViewModelTest.kt @@ -0,0 +1,163 @@ +package de.jeanlucmakiola.clockula.ui.shell + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.clockula.domain.StopwatchRun +import de.jeanlucmakiola.clockula.domain.StopwatchState +import de.jeanlucmakiola.clockula.domain.Timer +import de.jeanlucmakiola.clockula.domain.TimerState +import de.jeanlucmakiola.clockula.domain.live.LivePillMode +import de.jeanlucmakiola.clockula.domain.live.LivePillState +import de.jeanlucmakiola.clockula.domain.live.LivePillSubject +import de.jeanlucmakiola.clockula.testing.BERLIN +import de.jeanlucmakiola.clockula.testing.FakeElapsedRealtimeClock +import de.jeanlucmakiola.clockula.testing.FakeStopwatchRepository +import de.jeanlucmakiola.clockula.testing.FakeTicker +import de.jeanlucmakiola.clockula.testing.FakeTimerRepository +import de.jeanlucmakiola.clockula.testing.FakeWallClock +import de.jeanlucmakiola.clockula.testing.MainDispatcherExtension +import de.jeanlucmakiola.clockula.testing.instantAt +import de.jeanlucmakiola.clockula.testing.runningTimer +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes + +/** + * The pill's two buttons, and the promise behind them: the primary one pauses + * or resumes whatever the pill is showing, and Stop is always `reset` — the + * pill must not be able to destroy a timer the user will want again. + */ +class ShellViewModelTest { + + @JvmField + @RegisterExtension + val main = MainDispatcherExtension() + + private val now = 1.hours + private val wallInstant = instantAt(BERLIN, "2026-06-10T12:00") + + private val timers = FakeTimerRepository() + private val stopwatch = FakeStopwatchRepository() + private val ticker = FakeTicker() + + private fun viewModel() = ShellViewModel( + source = LivePillSource( + timers = timers, + stopwatch = stopwatch, + elapsed = FakeElapsedRealtimeClock(now), + wall = FakeWallClock(wallInstant), + ticker = ticker, + ), + timers = timers, + stopwatch = stopwatch, + ) + + private fun timerState(id: Long = 7L, mode: LivePillMode) = LivePillState( + subject = LivePillSubject.TimerSubject(id, "Pasta"), + mode = mode, + value = 4.minutes, + otherActiveTimers = 0, + ) + + private fun stopwatchState(mode: LivePillMode) = LivePillState( + subject = LivePillSubject.StopwatchSubject, + mode = mode, + value = 4.minutes, + otherActiveTimers = 0, + ) + + private fun runningTimerRow(id: Long = 7L, left: Duration = 4.minutes): Timer = + runningTimer().copy( + id = id, + state = TimerState.RUNNING, + remaining = left, + startedAtElapsedRealtime = Duration.ZERO, + endsAtElapsedRealtime = now + left, + endsAtWallClock = wallInstant + left, + ) + + @Test + fun `the pill starts empty and then follows the source`() = runTest(main.dispatcher) { + val viewModel = viewModel() + + viewModel.livePill.test { + assertThat(awaitItem()).isNull() + + timers.set(listOf(runningTimerRow())) + + assertThat(awaitItem()?.subject).isEqualTo(LivePillSubject.TimerSubject(7L, "Pasta")) + cancel() + } + } + + @Test + fun `the primary action pauses a running timer`() = runTest(main.dispatcher) { + viewModel().onPillPrimaryAction(timerState(mode = LivePillMode.RUNNING)) + + assertThat(timers.calls.last()).isEqualTo("pause(7)") + } + + @Test + fun `the primary action resumes a paused timer`() = runTest(main.dispatcher) { + viewModel().onPillPrimaryAction(timerState(mode = LivePillMode.PAUSED)) + + assertThat(timers.calls.last()).isEqualTo("start(7)") + } + + @Test + fun `the primary action pauses a running stopwatch`() = runTest(main.dispatcher) { + viewModel().onPillPrimaryAction(stopwatchState(mode = LivePillMode.RUNNING)) + + assertThat(stopwatch.calls.last()).isEqualTo("pause()") + } + + @Test + fun `the primary action resumes a paused stopwatch`() = runTest(main.dispatcher) { + viewModel().onPillPrimaryAction(stopwatchState(mode = LivePillMode.PAUSED)) + + assertThat(stopwatch.calls.last()).isEqualTo("start()") + } + + @Test + fun `an expired timer has nothing left to pause`() = runTest(main.dispatcher) { + viewModel().onPillPrimaryAction(timerState(mode = LivePillMode.EXPIRED)) + + assertThat(timers.calls + stopwatch.calls).isEmpty() + } + + @Test + fun `stop resets a timer in any mode and never deletes it`() = runTest(main.dispatcher) { + val viewModel = viewModel() + + LivePillMode.entries.forEach { viewModel.onPillStop(timerState(mode = it)) } + + assertThat(timers.calls).containsExactly("reset(7)", "reset(7)", "reset(7)") + } + + @Test + fun `stop resets the stopwatch`() = runTest(main.dispatcher) { + viewModel().onPillStop(stopwatchState(mode = LivePillMode.RUNNING)) + + assertThat(stopwatch.calls.last()).isEqualTo("reset()") + } + + @Test + fun `stopping the only active subject lowers the pill`() = runTest(main.dispatcher) { + timers.set(listOf(runningTimerRow())) + stopwatch.set(StopwatchRun(state = StopwatchState.IDLE)) + val viewModel = viewModel() + + viewModel.livePill.test { + val showing = awaitItem() + assertThat(showing).isNotNull() + + viewModel.onPillStop(showing!!) + + assertThat(awaitItem()).isNull() + cancel() + } + } +}