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.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
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.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.clockula.ui.crash.CrashReportActivity
|
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.clockula.ui.theme.ClockulaTheme
|
||||||
import de.jeanlucmakiola.floret.crash.CrashReportDialog
|
import de.jeanlucmakiola.floret.crash.CrashReportDialog
|
||||||
import de.jeanlucmakiola.floret.crash.CrashReporter
|
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||||
@@ -33,9 +29,11 @@ import de.jeanlucmakiola.floret.prefs.isDark
|
|||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Single activity. M1 scaffolding: it brings up the theme — now following the
|
* Single activity: the theme — following the stored appearance preference — the
|
||||||
* stored appearance preference — and the crash surface, and nothing else. The
|
* crash surface, and the four-tab shell with its live pill (docs/PLAN.md §9).
|
||||||
* four-tab shell and the live pill (docs/PLAN.md §9) land in M4.
|
* 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
|
@AndroidEntryPoint
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
@@ -84,7 +82,10 @@ class MainActivity : ComponentActivity() {
|
|||||||
darkTheme = darkTheme,
|
darkTheme = darkTheme,
|
||||||
dynamicColor = appearance.dynamicColor,
|
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 ->
|
pendingCrashReport?.let { report ->
|
||||||
CrashReportDialog(
|
CrashReportDialog(
|
||||||
report = report,
|
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. */
|
/** 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_LIGHT_SCRIM = Color.argb(0xe6, 0xFF, 0xFF, 0xFF)
|
||||||
private val NAV_BAR_DARK_SCRIM = Color.argb(0x80, 0x1b, 0x1b, 0x1b)
|
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) -->
|
<!-- Crash reporting (floret-kit core-crash) -->
|
||||||
<string name="crash_report_issue_title">Crash report</string>
|
<string name="crash_report_issue_title">Crash report</string>
|
||||||
<string name="report_issue_url" translatable="false">https://codeberg.org/jlmakiola/clockula/issues/new</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>
|
</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