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:
@@ -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<LivePillState?>(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()
|
||||
}
|
||||
}
|
||||
@@ -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<MainActivity>()
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user