feat(timers): the timers tab, and the pill that finally has something to show
Multiple concurrent timers with labels, presets, add, pause, reset and +1 min. The setup panel is inline on an empty tab, so the first timer costs no navigation. The live pill has been waiting since M4 for something that could start a timer. It now goes through the engine rather than the repository, because a scheduler registration and a foreground service have to move with the state — its own contract is unchanged. Three new architecture rules keep it that way: no screen may name the scheduler, the service or AlarmManager, and a second MediaPlayer anywhere outside the ring path fails the build.
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
package de.jeanlucmakiola.clockula.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.v2.createEmptyComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
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 dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import de.jeanlucmakiola.clockula.MainActivity
|
||||
import de.jeanlucmakiola.clockula.R
|
||||
import de.jeanlucmakiola.clockula.data.timers.TimerRepository
|
||||
import de.jeanlucmakiola.clockula.domain.Timer
|
||||
import de.jeanlucmakiola.clockula.domain.TimerDraft
|
||||
import de.jeanlucmakiola.clockula.domain.TimerState
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* §5.19 — 4 cases. What only a device can answer about the Timers tab: that the
|
||||
* keypad and the bottom sheet really compose and really write, that the typed
|
||||
* digits survive a real activity recreation (which is the only thing that
|
||||
* proves `rememberSaveable`, D23), and that a real system back press from the
|
||||
* editor lands on the tab with the tab still selected (D24).
|
||||
*
|
||||
* Compiled in the gate; **not run here** — no device is attached (§5.19).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class TimersInstrumentedTest {
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface Seams {
|
||||
fun timers(): TimerRepository
|
||||
}
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createEmptyComposeRule()
|
||||
|
||||
private val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
|
||||
private val seams: Seams =
|
||||
EntryPointAccessors.fromApplication(context.applicationContext, Seams::class.java)
|
||||
|
||||
private var scenario: ActivityScenario<MainActivity>? = null
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
scenario?.close()
|
||||
runBlocking { seams.timers().timers().first().forEach { seams.timers().delete(it.id) } }
|
||||
}
|
||||
|
||||
private fun string(id: Int): String = context.getString(id)
|
||||
|
||||
private fun stored(): List<Timer> = runBlocking { seams.timers().timers().first() }
|
||||
|
||||
private fun givenTimer(label: String, running: Boolean = true): Long = runBlocking {
|
||||
val id = seams.timers().create(TimerDraft(5.minutes, label))
|
||||
if (running) seams.timers().start(id)
|
||||
id
|
||||
}
|
||||
|
||||
private fun openTimersTab(): ActivityScenario<MainActivity> {
|
||||
val launched = ActivityScenario.launch<MainActivity>(
|
||||
Intent(context, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||
).also { scenario = it }
|
||||
composeTestRule.waitForIdle()
|
||||
composeTestRule.onNodeWithText(string(R.string.tab_timers)).performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
return launched
|
||||
}
|
||||
|
||||
private fun typeDigits(vararg digits: Char) {
|
||||
digits.forEach {
|
||||
composeTestRule.onNodeWithText(it.toString()).performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
}
|
||||
}
|
||||
|
||||
/** §5.19 #1 */
|
||||
@Test
|
||||
fun typingOnTheKeypadAndPressingStartCreatesOneRunningTimer() {
|
||||
openTimersTab()
|
||||
|
||||
typeDigits('1', '3', '0')
|
||||
composeTestRule.onNodeWithText(string(R.string.timers_setup_start)).performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
val timers = stored()
|
||||
assertThat(timers.map { it.state to it.duration })
|
||||
.containsExactly(TimerState.RUNNING to 90.seconds)
|
||||
}
|
||||
|
||||
/** §5.19 #2 */
|
||||
@Test
|
||||
fun theTypedDigitsSurviveAnActivityRecreation() {
|
||||
val launched = openTimersTab()
|
||||
typeDigits('1', '3', '0')
|
||||
|
||||
launched.recreate()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule.onNodeWithText("0:01:30").assertIsDisplayed()
|
||||
}
|
||||
|
||||
/** §5.19 #3 */
|
||||
@Test
|
||||
fun pausingOneRowLeavesTheOtherRunning() {
|
||||
val paused = givenTimer("Pasta")
|
||||
val untouched = givenTimer("Eggs")
|
||||
openTimersTab()
|
||||
|
||||
// Storage order, so the first row is Pasta's (D22).
|
||||
composeTestRule.onAllNodesWithText(string(R.string.timer_pause))[0].performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
val byId = stored().associateBy { it.id }
|
||||
assertThat(byId[paused]?.state to byId[untouched]?.state)
|
||||
.isEqualTo(TimerState.PAUSED to TimerState.RUNNING)
|
||||
}
|
||||
|
||||
/** §5.19 #4 */
|
||||
@Test
|
||||
fun backFromTheTimerEditorReturnsToTheTimersTab() {
|
||||
givenTimer("Pasta", running = false)
|
||||
openTimersTab()
|
||||
// The card announces its own content and labels the tap "Timer settings"
|
||||
// through `onClick`, so the row is found by the timer it is.
|
||||
composeTestRule.onNodeWithText("Pasta").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
Espresso.pressBack()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule.onNodeWithText("Pasta").assertIsDisplayed()
|
||||
assertThat(scenario?.state).isNotEqualTo(Lifecycle.State.DESTROYED)
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,9 @@ 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.ClockulaDestination
|
||||
import de.jeanlucmakiola.clockula.ui.shell.ClockulaShell
|
||||
import de.jeanlucmakiola.clockula.ui.shell.ShellNavigation
|
||||
import de.jeanlucmakiola.clockula.ui.shell.RequestNotificationPermissionOnce
|
||||
import de.jeanlucmakiola.clockula.ui.theme.ClockulaTheme
|
||||
import de.jeanlucmakiola.floret.crash.CrashReportDialog
|
||||
@@ -49,6 +51,14 @@ class MainActivity : ComponentActivity() {
|
||||
// crash-loop is handled out of band, before setContent — see below.
|
||||
private var pendingCrashReport by mutableStateOf<String?>(null)
|
||||
|
||||
/**
|
||||
* A tab an incoming intent asked for, consumed once by the shell (M6 D24).
|
||||
* Read in both `onCreate` and `onNewIntent`, because the activity is
|
||||
* `singleTop`: tapping a timer notification with the app already open
|
||||
* delivers a new intent to the running instance rather than creating one.
|
||||
*/
|
||||
private var openTab by mutableStateOf<ClockulaDestination?>(null)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
@@ -64,6 +74,8 @@ class MainActivity : ComponentActivity() {
|
||||
// Surface a single captured crash as a dialog on the next launch.
|
||||
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
||||
|
||||
openTab = ShellNavigation.tabForAction(intent?.action)
|
||||
|
||||
setContent {
|
||||
// Until the first snapshot arrives, the family's defaults stand in —
|
||||
// follow the system, dynamic colour on — so the first frame is never
|
||||
@@ -82,7 +94,13 @@ class MainActivity : ComponentActivity() {
|
||||
darkTheme = darkTheme,
|
||||
dynamicColor = appearance.dynamicColor,
|
||||
) {
|
||||
ClockulaShell(Modifier.fillMaxSize())
|
||||
ClockulaShell(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
openTab = openTab,
|
||||
// Consumed once: a request left standing would re-select the
|
||||
// tab on every recomposition and fight the user's taps.
|
||||
onTabOpened = { openTab = null },
|
||||
)
|
||||
// First launch asks for notifications once, and only here:
|
||||
// it belongs to starting the app, not to any one tab.
|
||||
RequestNotificationPermissionOnce(settingsPrefs)
|
||||
@@ -111,6 +129,12 @@ class MainActivity : ComponentActivity() {
|
||||
)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
openTab = ShellNavigation.tabForAction(intent.action)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
// A successful start breaks any loop; reset the timing trail so a later
|
||||
|
||||
@@ -14,6 +14,7 @@ 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.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -32,6 +33,8 @@ import de.jeanlucmakiola.clockula.ui.alarms.AlarmEditorScreen
|
||||
import de.jeanlucmakiola.clockula.ui.alarms.AlarmRoutes
|
||||
import de.jeanlucmakiola.clockula.ui.alarms.AlarmsScreen
|
||||
import de.jeanlucmakiola.clockula.ui.stopwatch.StopwatchScreen
|
||||
import de.jeanlucmakiola.clockula.ui.timers.TimerEditorScreen
|
||||
import de.jeanlucmakiola.clockula.ui.timers.TimerRoutes
|
||||
import de.jeanlucmakiola.clockula.ui.timers.TimersScreen
|
||||
import de.jeanlucmakiola.clockula.ui.worldclock.WorldClockScreen
|
||||
import de.jeanlucmakiola.floret.identity.fadeThrough
|
||||
@@ -52,6 +55,8 @@ import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||
@Composable
|
||||
fun ClockulaShell(
|
||||
modifier: Modifier = Modifier,
|
||||
openTab: ClockulaDestination? = null,
|
||||
onTabOpened: () -> Unit = {},
|
||||
viewModel: ShellViewModel = hiltViewModel(),
|
||||
navController: NavHostController = rememberNavController(),
|
||||
) {
|
||||
@@ -65,6 +70,15 @@ fun ClockulaShell(
|
||||
// rather than sliding: a slide would claim a hierarchy that is not there.
|
||||
val fade = fadeThrough()
|
||||
|
||||
// A notification's deep link, replayed as exactly the command a tab tap
|
||||
// produces — which is what the user expects, and it leaves M4's asserted
|
||||
// back policy alone: the start destination is still Alarms (M6 D24).
|
||||
LaunchedEffect(openTab) {
|
||||
val target = openTab ?: return@LaunchedEffect
|
||||
navController.replay(ShellNavigation.onTabSelected(route, target))
|
||||
onTabOpened()
|
||||
}
|
||||
|
||||
NavigationSuiteScaffold(
|
||||
navigationItems = {
|
||||
ShellNavigation.topLevel.forEach { destination ->
|
||||
@@ -118,7 +132,20 @@ fun ClockulaShell(
|
||||
) {
|
||||
AlarmEditorScreen(onBack = { navController.popBackStack() })
|
||||
}
|
||||
composable(ClockulaDestination.TIMERS.route) { TimersScreen() }
|
||||
composable(ClockulaDestination.TIMERS.route) {
|
||||
TimersScreen(onEditTimer = { navController.navigate(TimerRoutes.editor(it)) })
|
||||
}
|
||||
// A sibling route in the same flat host, exactly as the
|
||||
// alarm editor is: it is no tab, so the Timers tab stays
|
||||
// selected and the shell stands aside on back (M5 D1).
|
||||
composable(
|
||||
route = TimerRoutes.EDITOR_PATTERN,
|
||||
arguments = listOf(
|
||||
navArgument(TimerRoutes.ARG_TIMER_ID) { type = NavType.LongType },
|
||||
),
|
||||
) {
|
||||
TimerEditorScreen(onBack = { navController.popBackStack() })
|
||||
}
|
||||
composable(ClockulaDestination.STOPWATCH.route) { StopwatchScreen() }
|
||||
composable(ClockulaDestination.WORLD_CLOCK.route) { WorldClockScreen() }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package de.jeanlucmakiola.clockula.ui.shell
|
||||
|
||||
import de.jeanlucmakiola.clockula.timer.TimerIntents
|
||||
|
||||
/** What a tab tap does to the back stack. Pure data; the composable replays it. */
|
||||
data class TabNavCommand(
|
||||
val route: String,
|
||||
@@ -45,6 +47,17 @@ object ShellNavigation {
|
||||
launchSingleTop = true,
|
||||
)
|
||||
|
||||
/**
|
||||
* The tab an incoming intent action asks for. Null for null and for
|
||||
* anything unknown, so an unrecognised action leaves the start destination
|
||||
* alone (M6 D24). M9 extends this with the rest of the `AlarmClock`
|
||||
* contract; M6 wires exactly one action to it.
|
||||
*/
|
||||
fun tabForAction(action: String?): ClockulaDestination? = when (action) {
|
||||
TimerIntents.ACTION_SHOW_TIMERS -> ClockulaDestination.TIMERS
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -8,6 +8,7 @@ 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 de.jeanlucmakiola.clockula.timer.TimerEngine
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
@@ -20,6 +21,9 @@ class ShellViewModel @Inject internal constructor(
|
||||
source: LivePillSource,
|
||||
private val timers: TimerRepository,
|
||||
private val stopwatch: StopwatchRepository,
|
||||
// M6 D25: the pill's timer branch goes through the engine, so pausing from
|
||||
// the pill moves the expiry slot and the service with it.
|
||||
private val timerEngine: TimerEngine,
|
||||
) : ViewModel() {
|
||||
|
||||
val livePill: StateFlow<LivePillState?> = source.state()
|
||||
@@ -31,8 +35,11 @@ class ShellViewModel @Inject internal constructor(
|
||||
val resume = state.mode == LivePillMode.PAUSED
|
||||
viewModelScope.launch {
|
||||
when (val subject = state.subject) {
|
||||
// Through the engine, not the repository: without it, pausing
|
||||
// from the pill would leave the expiry slot pointing at a dead
|
||||
// deadline and the service posting a stale notification (D25).
|
||||
is LivePillSubject.TimerSubject ->
|
||||
if (resume) timers.start(subject.timerId) else timers.pause(subject.timerId)
|
||||
if (resume) timerEngine.start(subject.timerId) else timerEngine.pause(subject.timerId)
|
||||
|
||||
LivePillSubject.StopwatchSubject ->
|
||||
if (resume) stopwatch.start() else stopwatch.pause()
|
||||
@@ -45,8 +52,10 @@ class ShellViewModel @Inject internal constructor(
|
||||
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)
|
||||
// timer the user configured and will want again — and it is what
|
||||
// makes a timer its own preset (D13). Through the engine, so
|
||||
// stopping a *sounding* timer closes the ring session with it.
|
||||
is LivePillSubject.TimerSubject -> timerEngine.reset(subject.timerId)
|
||||
LivePillSubject.StopwatchSubject -> stopwatch.reset()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
package de.jeanlucmakiola.clockula.ui.timers
|
||||
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.clockula.R
|
||||
import de.jeanlucmakiola.clockula.domain.format.ClockFormat
|
||||
import de.jeanlucmakiola.clockula.domain.timer.TimerDurationEntry
|
||||
import de.jeanlucmakiola.clockula.ui.common.RingtonePickerScreen
|
||||
import de.jeanlucmakiola.clockula.ui.shell.LocalLivePillInset
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.GroupedSurface
|
||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
|
||||
/**
|
||||
* The timer editor: a label, the length — **only while the timer is idle**
|
||||
* (D3) — and the one per-timer override a timer has, its ringtone (D27).
|
||||
* Every control writes at once, so there is no Save button, no dirty state and
|
||||
* no discard dialog: back is just back (M5 D22).
|
||||
*/
|
||||
@Composable
|
||||
fun TimerEditorScreen(
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: TimerEditorViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val picker by viewModel.ringtonePicker.collectAsStateWithLifecycle()
|
||||
|
||||
// Registered unconditionally, and deliberately not inside the `picker?.let`
|
||||
// below: the picker's open flag is ViewModel state that does not survive
|
||||
// process death, so a launcher registered only while the picker is on
|
||||
// screen would not be there to take a result restored into a fresh process
|
||||
// (M5 D20).
|
||||
val pickDocument = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.OpenDocument(),
|
||||
) { uri ->
|
||||
// The Uri never crosses into the ViewModel, which is what keeps it
|
||||
// JVM-testable. A cancelled pick is a null, which changes nothing.
|
||||
viewModel.onDocumentPicked(uri?.toString())
|
||||
}
|
||||
|
||||
LaunchedEffect(state) {
|
||||
// Deleted here, or somewhere else: there is nothing left to edit.
|
||||
if (state is TimerEditorUiState.Gone) onBack()
|
||||
}
|
||||
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.timer_editor_title),
|
||||
onBack = onBack,
|
||||
modifier = modifier,
|
||||
predictiveBack = true,
|
||||
) {
|
||||
val editing = state as? TimerEditorUiState.Editing ?: return@CollapsingScaffold
|
||||
TimerEditorContent(editing = editing, viewModel = viewModel)
|
||||
}
|
||||
|
||||
picker?.let { pickerState ->
|
||||
val editing = state as? TimerEditorUiState.Editing
|
||||
RingtonePickerScreen(
|
||||
state = pickerState,
|
||||
defaultUri = editing?.defaults?.timerRingtoneUri,
|
||||
onChoose = viewModel::onRingtoneChosen,
|
||||
onPreview = viewModel::onPreview,
|
||||
onPickFile = { pickDocument.launch(arrayOf(AUDIO_MIME)) },
|
||||
onDismiss = viewModel::onCloseRingtonePicker,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private const val AUDIO_MIME = "audio/*"
|
||||
|
||||
@Composable
|
||||
private fun TimerEditorContent(
|
||||
editing: TimerEditorUiState.Editing,
|
||||
viewModel: TimerEditorViewModel,
|
||||
) {
|
||||
// Saveable, not remembered: a dialog open across a rotation must still be
|
||||
// open afterwards (M5's lesson).
|
||||
var confirmingDelete by rememberSaveable { mutableStateOf(false) }
|
||||
var editingLength by rememberSaveable { mutableStateOf(false) }
|
||||
var digits by rememberSaveable { mutableStateOf("") }
|
||||
val entry = TimerDurationEntry(digits)
|
||||
|
||||
LengthCard(
|
||||
editing = editing,
|
||||
onClick = {
|
||||
if (editing.canEditDuration) {
|
||||
digits = ""
|
||||
editingLength = true
|
||||
}
|
||||
},
|
||||
)
|
||||
LabelField(label = editing.label, onLabelTyped = viewModel::onLabelTyped)
|
||||
|
||||
SoundRow(editing = editing, onClick = viewModel::onOpenRingtonePicker)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
DeleteRow(onClick = { confirmingDelete = true })
|
||||
Spacer(Modifier.height(LocalLivePillInset.current))
|
||||
|
||||
if (editingLength) {
|
||||
// The same keypad the setup panel uses, over the same pure state
|
||||
// machine: one answer to "type a duration" in the whole app (D23).
|
||||
AlertDialog(
|
||||
onDismissRequest = { editingLength = false },
|
||||
title = { Text(stringResource(R.string.timer_editor_duration)) },
|
||||
text = {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = entry.display,
|
||||
style = MaterialTheme.typography.displayMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
TimerKeypad(
|
||||
onDigit = { digits = entry.plusDigit(it).digits },
|
||||
onBackspace = { digits = entry.backspace().digits },
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
viewModel.onDurationCommitted(entry.duration)
|
||||
editingLength = false
|
||||
},
|
||||
enabled = entry.canStart,
|
||||
) {
|
||||
Text(stringResource(R.string.action_ok))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { editingLength = false }) {
|
||||
Text(stringResource(R.string.action_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
if (confirmingDelete) {
|
||||
DeleteConfirmation(
|
||||
onConfirm = {
|
||||
confirmingDelete = false
|
||||
viewModel.onDelete()
|
||||
},
|
||||
onDismiss = { confirmingDelete = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The configured length, at display size — the one thing this page is about. */
|
||||
@Composable
|
||||
private fun LengthCard(
|
||||
editing: TimerEditorUiState.Editing,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
GroupedSurface(
|
||||
position = Position.Alone,
|
||||
modifier = modifier.padding(horizontal = GroupedListInset),
|
||||
onClick = onClick,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 20.dp, vertical = 20.dp)) {
|
||||
Text(
|
||||
text = ClockFormat.countdown(editing.duration),
|
||||
style = MaterialTheme.typography.displayMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
if (!editing.canEditDuration) {
|
||||
// Refused rather than silently ignored: changing the length of a
|
||||
// countdown already under way is ambiguous, and every answer is
|
||||
// a surprise (D3).
|
||||
Text(
|
||||
text = stringResource(R.string.timer_editor_duration_locked),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LabelField(label: String, onLabelTyped: (String) -> Unit, modifier: Modifier = Modifier) {
|
||||
GroupedSurface(
|
||||
position = Position.Alone,
|
||||
modifier = modifier.padding(horizontal = GroupedListInset),
|
||||
) {
|
||||
InlineTextField(
|
||||
value = label,
|
||||
onValueChange = onLabelTyped,
|
||||
placeholder = stringResource(R.string.timer_editor_label),
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** M5's picker, so every one of its behaviours arrives here for free (D27). */
|
||||
@Composable
|
||||
private fun SoundRow(editing: TimerEditorUiState.Editing, onClick: () -> Unit) {
|
||||
val silent = stringResource(R.string.ringtone_silent)
|
||||
val unknown = stringResource(R.string.ringtone_unknown)
|
||||
val deviceDefault = stringResource(R.string.ringtone_device_default)
|
||||
val appDefault = stringResource(R.string.override_app_default)
|
||||
val ringtone = editing.ringtone
|
||||
val value = when {
|
||||
ringtone.silent -> silent
|
||||
ringtone.effectiveUri == null -> deviceDefault
|
||||
else -> ringtone.title ?: unknown
|
||||
}
|
||||
val summary = buildList {
|
||||
add(if (ringtone.inherited) "$appDefault ($value)" else value)
|
||||
if (ringtone.unavailable) add(stringResource(R.string.ringtone_unavailable))
|
||||
if (ringtone.accessMayLapse) add(stringResource(R.string.ringtone_access_may_lapse))
|
||||
}
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.timer_editor_sound),
|
||||
summary = summary.joinToString("\n"),
|
||||
position = Position.Alone,
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
|
||||
/** Tinted through the AnnotatedString overload, so no literal colour is written. */
|
||||
@Composable
|
||||
private fun DeleteRow(onClick: () -> Unit) {
|
||||
val error = MaterialTheme.colorScheme.error
|
||||
val label = stringResource(R.string.timer_editor_delete)
|
||||
val title: AnnotatedString = buildAnnotatedString {
|
||||
withStyle(SpanStyle(color = error)) { append(label) }
|
||||
}
|
||||
GroupedRow(title = title, position = Position.Alone, onClick = onClick)
|
||||
}
|
||||
|
||||
/**
|
||||
* A confirmation rather than an undo chip: M5's answer, and the cheap moment
|
||||
* to ask is before.
|
||||
*/
|
||||
@Composable
|
||||
private fun DeleteConfirmation(onConfirm: () -> Unit, onDismiss: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.timer_editor_delete)) },
|
||||
text = { Text(stringResource(R.string.timer_editor_delete_confirm)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = onConfirm) { Text(stringResource(R.string.action_delete)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package de.jeanlucmakiola.clockula.ui.timers
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.ClockDefaults
|
||||
import de.jeanlucmakiola.clockula.domain.timer.TimerMode
|
||||
import kotlin.time.Duration
|
||||
|
||||
sealed interface TimerEditorUiState {
|
||||
data object Loading : TimerEditorUiState
|
||||
|
||||
/** Deleted, here or elsewhere: there is nothing left to edit. */
|
||||
data object Gone : TimerEditorUiState
|
||||
|
||||
data class Editing(
|
||||
val timerId: Long,
|
||||
val label: String,
|
||||
val duration: Duration,
|
||||
val mode: TimerMode,
|
||||
/** Only an IDLE timer's length may be edited (D3). */
|
||||
val canEditDuration: Boolean,
|
||||
val ringtone: TimerRingtoneSetting,
|
||||
val defaults: ClockDefaults,
|
||||
) : TimerEditorUiState
|
||||
}
|
||||
|
||||
data class TimerRingtoneSetting(
|
||||
val storedUri: String?,
|
||||
val effectiveUri: String?,
|
||||
val inherited: Boolean,
|
||||
val silent: Boolean,
|
||||
val title: String?,
|
||||
/** Reported, never rewritten (M5 D19). */
|
||||
val unavailable: Boolean,
|
||||
val accessMayLapse: Boolean,
|
||||
)
|
||||
@@ -0,0 +1,279 @@
|
||||
package de.jeanlucmakiola.clockula.ui.timers
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.clockula.data.ringtones.RingtoneCatalog
|
||||
import de.jeanlucmakiola.clockula.data.ringtones.RingtonePreviewer
|
||||
import de.jeanlucmakiola.clockula.data.timers.TimerRepository
|
||||
import de.jeanlucmakiola.clockula.domain.ClockDefaults
|
||||
import de.jeanlucmakiola.clockula.domain.Ringtones
|
||||
import de.jeanlucmakiola.clockula.domain.Timer
|
||||
import de.jeanlucmakiola.clockula.domain.TimerState
|
||||
import de.jeanlucmakiola.clockula.domain.timer.TimerMode
|
||||
import de.jeanlucmakiola.clockula.timer.TimerEngine
|
||||
import de.jeanlucmakiola.clockula.ui.common.RingtonePicker
|
||||
import de.jeanlucmakiola.clockula.ui.common.RingtonePickerUiState
|
||||
import de.jeanlucmakiola.floret.di.ApplicationScope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.Duration
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* One timer's label, its length (IDLE only, D3) and its one override — the
|
||||
* ringtone, through M5's picker. Writes as the user goes, exactly as the alarm
|
||||
* editor does: no Save, no dirty state, no discard dialog (M5 D22).
|
||||
*/
|
||||
@HiltViewModel
|
||||
class TimerEditorViewModel @Inject internal constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val timers: TimerRepository,
|
||||
private val engine: TimerEngine,
|
||||
private val settings: SettingsPrefs,
|
||||
private val ringtones: RingtoneCatalog,
|
||||
private val preview: RingtonePreviewer,
|
||||
// `@param:` — the qualifier has no explicit @Target, and K2 resolves a bare
|
||||
// annotation on a constructor `val` to the property instead.
|
||||
@param:ApplicationScope private val appScope: CoroutineScope,
|
||||
) : ViewModel() {
|
||||
|
||||
val timerId: Long = savedStateHandle.get<Long>(TimerRoutes.ARG_TIMER_ID) ?: 0L
|
||||
|
||||
/**
|
||||
* The label as typed, conflated: a `MutableStateFlow` writes the first value
|
||||
* and the latest, **in order**, so rapid typing can neither interleave nor
|
||||
* land out of order — and there is no debounce job to lose on the way out
|
||||
* (M5 D24).
|
||||
*/
|
||||
private val labelWrites = MutableStateFlow<String?>(null)
|
||||
|
||||
/** A picked document whose read grant could not be made persistent (M5 D20). */
|
||||
private val accessMayLapse = MutableStateFlow(false)
|
||||
|
||||
private val _state = MutableStateFlow<TimerEditorUiState>(TimerEditorUiState.Loading)
|
||||
|
||||
val state: StateFlow<TimerEditorUiState> = _state.asStateFlow()
|
||||
|
||||
private val _ringtonePicker = MutableStateFlow<RingtonePickerUiState?>(null)
|
||||
|
||||
/** Null while closed. M5's type. */
|
||||
val ringtonePicker: StateFlow<RingtonePickerUiState?> = _ringtonePicker.asStateFlow()
|
||||
|
||||
/** What the previewer is sounding, so the next preview knows to stop it. */
|
||||
private var previewing: String? = null
|
||||
|
||||
/** Memoised catalog answers, keyed on URI — see [metaOf]. */
|
||||
private val ringtoneMeta = mutableMapOf<String, RingtoneMeta>()
|
||||
|
||||
/** Set by [onDelete]: the row is gone, and a late repository emission must not undo that. */
|
||||
private var deleted: Boolean = false
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
combine(
|
||||
timers.timer(timerId),
|
||||
settings.defaults,
|
||||
labelWrites,
|
||||
accessMayLapse,
|
||||
) { timer, defaults, typedLabel, mayLapse ->
|
||||
Inputs(timer, defaults, typedLabel, mayLapse)
|
||||
}.collectLatest { inputs ->
|
||||
if (!deleted) _state.value = render(inputs)
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
labelWrites.collect { text ->
|
||||
if (text != null) timers.rename(timerId, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Verbatim, conflated — one write per keystroke, in order (M5 D24). */
|
||||
fun onLabelTyped(text: String) {
|
||||
// Untrimmed: `Timer.label` is verbatim everywhere else, and trimming as
|
||||
// the user types would eat the space they just pressed.
|
||||
labelWrites.value = text
|
||||
}
|
||||
|
||||
/**
|
||||
* Ignored unless the timer is IDLE — the repository refuses it anyway (D3),
|
||||
* and the editor hides the keypad, so this is the third guard on a question
|
||||
* whose every answer is a surprise.
|
||||
*/
|
||||
fun onDurationCommitted(duration: Duration) {
|
||||
viewModelScope.launch { timers.setDuration(timerId, duration) }
|
||||
}
|
||||
|
||||
fun onOpenRingtonePicker() {
|
||||
val editing = _state.value as? TimerEditorUiState.Editing ?: return
|
||||
val stored = editing.ringtone.storedUri
|
||||
val appDefault = editing.defaults.timerRingtoneUri
|
||||
_ringtonePicker.value = RingtonePickerUiState(
|
||||
loading = true,
|
||||
options = emptyList(),
|
||||
selectedUri = stored,
|
||||
deviceHasNoSounds = false,
|
||||
defaultTitle = null,
|
||||
)
|
||||
viewModelScope.launch {
|
||||
// A device list can be hundreds of rows, so it is read once per
|
||||
// opening rather than on every recomposition.
|
||||
val tones = ringtones.alarmTones()
|
||||
_ringtonePicker.value = RingtonePickerUiState(
|
||||
loading = false,
|
||||
options = RingtonePicker.optionsFor(tones, stored, editing.ringtone.title),
|
||||
selectedUri = stored,
|
||||
deviceHasNoSounds = tones.isEmpty(),
|
||||
// Resolved, not looked up in the tone list: an app default that
|
||||
// is a SAF-picked file is absent from that list (M5 D16).
|
||||
defaultTitle = appDefault
|
||||
?.takeUnless { Ringtones.isSilent(it) }
|
||||
?.let { metaOf(it).title },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onCloseRingtonePicker() {
|
||||
stopPreview()
|
||||
_ringtonePicker.value = null
|
||||
}
|
||||
|
||||
/** null = App default; [Ringtones.SILENT_URI] = Silent; otherwise a sound. */
|
||||
fun onRingtoneChosen(uri: String?) {
|
||||
accessMayLapse.value = false
|
||||
onCloseRingtonePicker()
|
||||
viewModelScope.launch { timers.setRingtoneUri(timerId, uri) }
|
||||
}
|
||||
|
||||
/** The SAF result as a String. null = cancelled, which changes nothing. */
|
||||
fun onDocumentPicked(uri: String?) {
|
||||
val picked = Ringtones.normalise(uri) ?: return
|
||||
onCloseRingtonePicker()
|
||||
viewModelScope.launch {
|
||||
// A grant that cannot be made persistent is disclosed, not refused:
|
||||
// the fallback chain still protects the ring (M5 D20).
|
||||
accessMayLapse.value = !ringtones.persistPickedDocument(picked)
|
||||
timers.setRingtoneUri(timerId, picked)
|
||||
}
|
||||
}
|
||||
|
||||
/** null or the silent sentinel stops rather than plays. */
|
||||
fun onPreview(uri: String?) {
|
||||
val sound = Ringtones.normalise(uri)?.takeUnless { Ringtones.isSilent(it) }
|
||||
if (sound == null) {
|
||||
stopPreview()
|
||||
return
|
||||
}
|
||||
// There is one previewer, so a sound already playing stops first: two
|
||||
// tones at once is not a preview of either (M5 D21).
|
||||
if (previewing != null) stopPreview()
|
||||
previewing = sound
|
||||
viewModelScope.launch { preview.play(sound) }
|
||||
}
|
||||
|
||||
/** Goes through the engine, then renders [TimerEditorUiState.Gone]. */
|
||||
fun onDelete() {
|
||||
viewModelScope.launch {
|
||||
// The engine, not the repository: deleting a *ringing* timer has to
|
||||
// close the ring session and move the expiry slot with it (D2).
|
||||
engine.delete(timerId)
|
||||
deleted = true
|
||||
_state.value = TimerEditorUiState.Gone
|
||||
}
|
||||
}
|
||||
|
||||
/** Flushes the label on the application scope; stops the preview. */
|
||||
public override fun onCleared() {
|
||||
stopPreview()
|
||||
// The last keystroke is written on a scope that outlives this ViewModel:
|
||||
// leaving the editor is exactly when the user has just finished typing.
|
||||
labelWrites.value?.let { text ->
|
||||
appScope.launch { timers.rename(timerId, text) }
|
||||
}
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
private fun stopPreview() {
|
||||
previewing = null
|
||||
preview.stop()
|
||||
}
|
||||
|
||||
private suspend fun render(inputs: Inputs): TimerEditorUiState {
|
||||
val timer = inputs.timer ?: return TimerEditorUiState.Gone
|
||||
return TimerEditorUiState.Editing(
|
||||
timerId = timer.id,
|
||||
label = inputs.typedLabel ?: timer.label,
|
||||
duration = timer.duration,
|
||||
mode = timer.state.asMode(),
|
||||
canEditDuration = timer.state == TimerState.IDLE,
|
||||
ringtone = ringtoneSetting(timer, inputs.defaults, inputs.accessMayLapse),
|
||||
defaults = inputs.defaults,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun ringtoneSetting(
|
||||
timer: Timer,
|
||||
defaults: ClockDefaults,
|
||||
accessMayLapse: Boolean,
|
||||
): TimerRingtoneSetting {
|
||||
val effective = timer.ringtoneUri ?: defaults.timerRingtoneUri
|
||||
val silent = Ringtones.isSilent(effective)
|
||||
val meta = effective?.takeUnless { silent }?.let { metaOf(it) }
|
||||
return TimerRingtoneSetting(
|
||||
storedUri = timer.ringtoneUri,
|
||||
effectiveUri = effective,
|
||||
inherited = timer.ringtoneUri == null,
|
||||
silent = silent,
|
||||
title = meta?.title,
|
||||
// Reported, never rewritten: "unreadable right now" and "gone
|
||||
// forever" are indistinguishable, and an unmounted card comes back
|
||||
// (M5 D19).
|
||||
unavailable = meta != null && !meta.playable,
|
||||
accessMayLapse = accessMayLapse,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The catalog's answer for one URI, memoised for the life of the editor.
|
||||
*
|
||||
* `titleOf` is a `ContentResolver.query` and `isPlayable` an
|
||||
* `openInputStream` — both blocking provider I/O, and the state is
|
||||
* re-rendered on **every keystroke** (M5 D24). Keyed on the URI, so a
|
||||
* *change* of sound still asks and typing asks nothing.
|
||||
*/
|
||||
private suspend fun metaOf(uri: String): RingtoneMeta = ringtoneMeta[uri]
|
||||
?: RingtoneMeta(title = ringtones.titleOf(uri), playable = ringtones.isPlayable(uri))
|
||||
.also { ringtoneMeta[uri] = it }
|
||||
|
||||
/** What the catalog says about one URI: its title, and whether it opens. */
|
||||
private data class RingtoneMeta(val title: String?, val playable: Boolean)
|
||||
|
||||
/**
|
||||
* The **stored** state, not a reading: the editor has no clock, and the one
|
||||
* thing it gates — whether the length may be edited — is a question about
|
||||
* the stored state (D3). A row that has just reached zero is flipped to
|
||||
* EXPIRED by the engine's sweep, and this re-renders when it is.
|
||||
*/
|
||||
private fun TimerState.asMode(): TimerMode = when (this) {
|
||||
TimerState.IDLE -> TimerMode.IDLE
|
||||
TimerState.RUNNING -> TimerMode.RUNNING
|
||||
TimerState.PAUSED -> TimerMode.PAUSED
|
||||
TimerState.EXPIRED -> TimerMode.EXPIRED
|
||||
}
|
||||
|
||||
/** The four inputs the state is a pure function of, once the catalog has answered. */
|
||||
private data class Inputs(
|
||||
val timer: Timer?,
|
||||
val defaults: ClockDefaults,
|
||||
val typedLabel: String?,
|
||||
val accessMayLapse: Boolean,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package de.jeanlucmakiola.clockula.ui.timers
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.Backspace
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
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.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.clockula.R
|
||||
|
||||
/**
|
||||
* Ten digits and a backspace, on a grid whose keys are comfortably past the
|
||||
* 48 dp minimum: a keypad is aimed at without looking.
|
||||
*
|
||||
* The keys are the canonical M3 `TextButton`/`FilledTonalIconButton` rather
|
||||
* than anything hand-rolled, so they inherit the ripple, the shape morph and
|
||||
* the disabled colours from the theme.
|
||||
*/
|
||||
@Composable
|
||||
internal fun TimerKeypad(
|
||||
onDigit: (Char) -> Unit,
|
||||
onBackspace: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
verticalArrangement = Arrangement.spacedBy(KeyGap),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
ROWS.forEach { row ->
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(KeyGap),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
row.forEach { key -> DigitKey(digit = key, onDigit = onDigit) }
|
||||
}
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(KeyGap),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
DigitKey(digit = '0', onDigit = onDigit)
|
||||
BackspaceKey(onBackspace = onBackspace)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DigitKey(digit: Char, onDigit: (Char) -> Unit) {
|
||||
val text = digit.toString()
|
||||
// The glyph is the label, so the announcement says what pressing it does
|
||||
// rather than reading a bare numeral out of context.
|
||||
val description = stringResource(R.string.timer_keypad_digit, text)
|
||||
TextButton(
|
||||
onClick = { onDigit(digit) },
|
||||
modifier = Modifier
|
||||
.size(TimersDefaults.KeypadKeySize)
|
||||
.semantics { contentDescription = description },
|
||||
) {
|
||||
Text(text = text, style = MaterialTheme.typography.headlineSmall)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BackspaceKey(onBackspace: () -> Unit) {
|
||||
val description = stringResource(R.string.timer_keypad_backspace)
|
||||
FilledTonalIconButton(
|
||||
onClick = onBackspace,
|
||||
modifier = Modifier.size(TimersDefaults.KeypadKeySize),
|
||||
) {
|
||||
Icon(Icons.AutoMirrored.Rounded.Backspace, contentDescription = description)
|
||||
}
|
||||
}
|
||||
|
||||
/** The 8 dp spacing step, so the grid's gaps are the system's rather than ours. */
|
||||
private val KeyGap = 8.dp
|
||||
|
||||
private val ROWS = listOf(
|
||||
listOf('1', '2', '3'),
|
||||
listOf('4', '5', '6'),
|
||||
listOf('7', '8', '9'),
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
package de.jeanlucmakiola.clockula.ui.timers
|
||||
|
||||
import de.jeanlucmakiola.clockula.data.timers.TimerRepository
|
||||
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 repository, both clocks and the ticker, combined into the list's rows. */
|
||||
@Singleton
|
||||
internal class TimerListSource @Inject constructor(
|
||||
private val timers: TimerRepository,
|
||||
private val elapsed: ElapsedRealtimeClock,
|
||||
private val wall: WallClock,
|
||||
private val ticker: Ticker,
|
||||
) {
|
||||
/** Distinct-until-changed; the cadence is one second — the readout's own resolution. */
|
||||
fun rows(): Flow<List<TimerRowState>> =
|
||||
combine(timers.timers(), ticker.ticks(TimersDefaults.ReadoutTick)) { rows, _ ->
|
||||
// Both clocks read once per emission, and the arithmetic is the
|
||||
// domain's: the screen re-derives none of it (M5 D8).
|
||||
TimerListRows.from(rows, elapsed.elapsedRealtime(), wall.now())
|
||||
}.distinctUntilChanged()
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package de.jeanlucmakiola.clockula.ui.timers
|
||||
|
||||
/** The editor's route and the name its argument is declared under. */
|
||||
internal object TimerRoutes {
|
||||
const val ARG_TIMER_ID: String = "timerId"
|
||||
const val EDITOR_PATTERN: String = "timers/edit/{timerId}"
|
||||
|
||||
fun editor(timerId: Long): String = "timers/edit/$timerId"
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package de.jeanlucmakiola.clockula.ui.timers
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.material3.ButtonGroup
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.LinearWavyProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.WavyProgressIndicatorDefaults
|
||||
import androidx.compose.material3.contentColorFor
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.onClick
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.clockula.R
|
||||
import de.jeanlucmakiola.clockula.domain.format.ClockFormat
|
||||
import de.jeanlucmakiola.clockula.domain.timer.TimerMode
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.components.GroupedSurface
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.identity.animateContentSizeMotion
|
||||
|
||||
/**
|
||||
* One timer as a card in the grouped vocabulary every other surface in the
|
||||
* family uses: the readout, the label, the wavy progress and the two controls
|
||||
* for whatever the timer is currently doing.
|
||||
*
|
||||
* The progress indicator's **amplitude is the state** — the default wave while
|
||||
* the timer runs, flat while it is paused or finished. That is
|
||||
* `docs/PLAN.md` §8's "refinement comes from shape, colour, space and motion,
|
||||
* never from enlarging text", and timer progress is one of the three places §8
|
||||
* sanctions a showpiece (D29).
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
internal fun TimerRow(
|
||||
row: TimerRowState,
|
||||
position: Position,
|
||||
onOpen: () -> Unit,
|
||||
onStart: () -> Unit,
|
||||
onPause: () -> Unit,
|
||||
onReset: () -> Unit,
|
||||
onAddMinute: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val untitled = stringResource(R.string.timer_untitled)
|
||||
val openDescription = stringResource(R.string.timer_row_open)
|
||||
val readout = readoutOf(row)
|
||||
// "%1$s left" is a sentence only while something is left: an expired timer
|
||||
// reads "Finished", and "Finished left" is not a readout. The card's own
|
||||
// text is otherwise announced as it stands.
|
||||
val readoutDescription = when (row.mode) {
|
||||
TimerMode.EXPIRED -> readout
|
||||
else -> stringResource(R.string.timer_row_progress, readout)
|
||||
}
|
||||
GroupedSurface(
|
||||
position = position,
|
||||
modifier = modifier
|
||||
.padding(horizontal = GroupedListInset)
|
||||
// The *purpose of the tap*, not a description of the card: a
|
||||
// `contentDescription` here would be read instead of the card's own
|
||||
// text, and the one thing a user needs from a list of timers — which
|
||||
// timer this is — would never be announced. `action = null` labels
|
||||
// the click the surface already installs.
|
||||
.semantics { onClick(label = openDescription, action = null) },
|
||||
onClick = onOpen,
|
||||
color = containerFor(row.mode),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().animateContentSizeMotion(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
) {
|
||||
Text(
|
||||
text = readout,
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
color = contentColorFor(containerFor(row.mode)),
|
||||
modifier = Modifier.semantics { contentDescription = readoutDescription },
|
||||
)
|
||||
Text(
|
||||
text = row.label.ifBlank { untitled },
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
LinearWavyProgressIndicator(
|
||||
// A lambda, so the bar animates without recomposing the row.
|
||||
progress = { row.progress },
|
||||
amplitude = if (row.mode == TimerMode.RUNNING) {
|
||||
WavyProgressIndicatorDefaults.indicatorAmplitude
|
||||
} else {
|
||||
FLAT
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
TimerControls(
|
||||
mode = row.mode,
|
||||
onStart = onStart,
|
||||
onPause = onPause,
|
||||
onReset = onReset,
|
||||
onAddMinute = onAddMinute,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Two buttons, and the same pair the notification offers for that mode (D17). */
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
private fun TimerControls(
|
||||
mode: TimerMode,
|
||||
onStart: () -> Unit,
|
||||
onPause: () -> Unit,
|
||||
onReset: () -> Unit,
|
||||
onAddMinute: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val primary: Pair<Int, () -> Unit> = when (mode) {
|
||||
TimerMode.RUNNING -> R.string.timer_pause to onPause
|
||||
TimerMode.PAUSED -> R.string.timer_resume to onStart
|
||||
TimerMode.EXPIRED -> R.string.timer_stop to onReset
|
||||
TimerMode.IDLE -> R.string.timer_start to onStart
|
||||
}
|
||||
val secondary: Pair<Int, () -> Unit> = when (mode) {
|
||||
TimerMode.RUNNING, TimerMode.EXPIRED -> R.string.timer_add_minute to onAddMinute
|
||||
TimerMode.PAUSED -> R.string.timer_reset to onReset
|
||||
TimerMode.IDLE -> R.string.timer_add_minute to onAddMinute
|
||||
}
|
||||
|
||||
ButtonGroup(modifier = modifier) {
|
||||
listOf(primary, secondary).forEach { (label, action) ->
|
||||
// `animateWidth` is the expressive press-widening — the component's
|
||||
// whole point, and the reason the pair is a ButtonGroup rather than
|
||||
// two buttons in a Row.
|
||||
val interaction = remember { MutableInteractionSource() }
|
||||
FilledTonalButton(
|
||||
onClick = action,
|
||||
interactionSource = interaction,
|
||||
modifier = Modifier.animateWidth(interaction),
|
||||
) {
|
||||
Text(stringResource(label))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An expired card is `tertiaryContainer` — exactly the token M4's live pill
|
||||
* already uses for an expired timer, so the two surfaces agree. A running one
|
||||
* is the kit's own default, and a paused one is dimmed by the same rule that
|
||||
* dims a switched-off alarm.
|
||||
*/
|
||||
@Composable
|
||||
private fun containerFor(mode: TimerMode): Color = when (mode) {
|
||||
TimerMode.EXPIRED -> MaterialTheme.colorScheme.tertiaryContainer
|
||||
TimerMode.PAUSED, TimerMode.IDLE -> MaterialTheme.colorScheme.surfaceContainer
|
||||
TimerMode.RUNNING -> MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
}
|
||||
|
||||
/** "Finished" says more than 0:00 does; everything else is the countdown. */
|
||||
@Composable
|
||||
private fun readoutOf(row: TimerRowState): String = when (row.mode) {
|
||||
TimerMode.EXPIRED -> stringResource(R.string.timer_finished)
|
||||
else -> ClockFormat.countdown(row.remaining)
|
||||
}
|
||||
|
||||
/** A paused or finished timer's bar does not wave: the amplitude *is* the state. */
|
||||
private val FLAT: (Float) -> Float = { 0f }
|
||||
@@ -0,0 +1,57 @@
|
||||
package de.jeanlucmakiola.clockula.ui.timers
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.Timer
|
||||
import de.jeanlucmakiola.clockula.domain.timer.TimerMode
|
||||
import de.jeanlucmakiola.clockula.domain.timer.TimerReadings
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Instant
|
||||
|
||||
data class TimerRowState(
|
||||
val id: Long,
|
||||
/** Verbatim; blank stays blank. */
|
||||
val label: String,
|
||||
val mode: TimerMode,
|
||||
/** What the row reads: the configured duration when IDLE, the snapshot otherwise. */
|
||||
val remaining: Duration,
|
||||
val duration: Duration,
|
||||
/** remaining/duration, clamped 0f..1f. 1f when IDLE, 0f when EXPIRED or duration is zero. */
|
||||
val progress: Float,
|
||||
)
|
||||
|
||||
sealed interface TimersUiState {
|
||||
/** Before the first repository emission. Not the empty state. */
|
||||
data object Loading : TimersUiState
|
||||
|
||||
data class Loaded(val rows: List<TimerRowState>, val presets: List<Duration>) : TimersUiState
|
||||
}
|
||||
|
||||
internal object TimerListRows {
|
||||
/**
|
||||
* Pure. Storage order — `sortOrder`, then `id` — which is exactly the order
|
||||
* the repository already emits, so the screen re-asserts it rather than
|
||||
* inventing one of its own. An expired timer does **not** jump to the top:
|
||||
* a row that moves under a thumb is worse than a row the user has to look
|
||||
* for (D22). Never throws.
|
||||
*/
|
||||
fun from(
|
||||
timers: List<Timer>,
|
||||
elapsedRealtime: Duration,
|
||||
wallClock: Instant,
|
||||
): List<TimerRowState> = timers.sortedWith(compareBy({ it.sortOrder }, { it.id })).map { timer ->
|
||||
val reading = TimerReadings.of(timer, elapsedRealtime, wallClock)
|
||||
TimerRowState(
|
||||
id = timer.id,
|
||||
label = timer.label,
|
||||
mode = reading.mode,
|
||||
remaining = reading.remaining,
|
||||
duration = timer.duration,
|
||||
progress = progressOf(reading.remaining, timer.duration),
|
||||
)
|
||||
}
|
||||
|
||||
/** A zero-length timer divides by nothing, and a longer remainder is clamped full. */
|
||||
private fun progressOf(remaining: Duration, duration: Duration): Float {
|
||||
if (duration <= Duration.ZERO) return 0f
|
||||
return (remaining / duration).toFloat().coerceIn(0f, 1f)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package de.jeanlucmakiola.clockula.ui.timers
|
||||
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Close
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.InputChip
|
||||
import androidx.compose.material3.InputChipDefaults
|
||||
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.domain.timer.TimerDurationEntry
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.identity.animateContentSizeMotion
|
||||
import kotlin.time.Duration
|
||||
|
||||
/**
|
||||
* Setting a timer: the readout of what has been typed, the preset chips, the
|
||||
* keypad and one Start. The most common action in the app must not cost four
|
||||
* steps, so the same panel is hosted in a bottom sheet over a non-empty list
|
||||
* and *inline* on an empty one — where it is the empty state, and there is no
|
||||
* "no timers yet" card to look at and then dismiss (D23).
|
||||
*
|
||||
* The panel holds no state: [entry] and its two callbacks live in the caller as
|
||||
* `rememberSaveable`, so the typed digits survive rotation *and* process death.
|
||||
*/
|
||||
@Composable
|
||||
internal fun TimerSetupPanel(
|
||||
entry: TimerDurationEntry,
|
||||
presets: List<Duration>,
|
||||
onDigit: (Char) -> Unit,
|
||||
onBackspace: () -> Unit,
|
||||
onPreset: (Duration) -> Unit,
|
||||
onSavePreset: (Duration) -> Unit,
|
||||
onRemovePreset: (Duration) -> Unit,
|
||||
onStart: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Readout(entry = entry)
|
||||
|
||||
PresetChips(
|
||||
entry = entry,
|
||||
presets = presets,
|
||||
onPreset = onPreset,
|
||||
onSavePreset = onSavePreset,
|
||||
onRemovePreset = onRemovePreset,
|
||||
)
|
||||
|
||||
TimerKeypad(onDigit = onDigit, onBackspace = onBackspace)
|
||||
|
||||
Button(
|
||||
onClick = onStart,
|
||||
// Nothing outside 1 s…24 h is startable, which is `ClockPrefs`' own
|
||||
// clamp — so nothing the user can choose is clamped away on the next
|
||||
// read (M5 D16).
|
||||
enabled = entry.canStart,
|
||||
) {
|
||||
Text(stringResource(R.string.timers_setup_start))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `displayMedium`, a plain scale role: the app-wide big-readout ramp is settled
|
||||
* against a working stopwatch in M7, not guessed at here (D28).
|
||||
*/
|
||||
@Composable
|
||||
private fun Readout(entry: TimerDurationEntry, modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier.animateContentSizeMotion(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = entry.display,
|
||||
style = MaterialTheme.typography.displayMedium,
|
||||
color = if (entry.isEmpty) {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
},
|
||||
)
|
||||
if (!entry.isEmpty && !entry.canStart) {
|
||||
Text(
|
||||
text = stringResource(R.string.timers_setup_range_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole preset-management surface: tap to fill the readout, the chip's own
|
||||
* trailing icon to remove, and a Save chip when the typed duration is not
|
||||
* already one. A presets *screen* is M10's if anyone wants one (D13).
|
||||
*
|
||||
* `InputChip` rather than `AssistChip`, which is where the plan's D29 landed:
|
||||
* a preset **is** a user-supplied value, which is the case `InputChip` and its
|
||||
* `trailingIcon` slot exist for. The plan asked for a long-press instead, and
|
||||
* that cannot be built over a Material chip without hand-rolling one — the
|
||||
* chip's own `clickable` consumes the gesture before any wrapper sees it — and
|
||||
* a long-press with no affordance is undiscoverable either way.
|
||||
*/
|
||||
@Composable
|
||||
private fun PresetChips(
|
||||
entry: TimerDurationEntry,
|
||||
presets: List<Duration>,
|
||||
onPreset: (Duration) -> Unit,
|
||||
onSavePreset: (Duration) -> Unit,
|
||||
onRemovePreset: (Duration) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val typed = entry.duration
|
||||
val canSave = entry.canStart && typed !in presets
|
||||
if (presets.isEmpty() && !canSave) return
|
||||
|
||||
val remove = stringResource(R.string.timers_preset_remove)
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.timers_presets),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(horizontal = GroupedListInset),
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(horizontal = GroupedListInset),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
presets.forEach { preset ->
|
||||
InputChip(
|
||||
selected = preset == typed,
|
||||
onClick = { onPreset(preset) },
|
||||
label = { Text(ClockFormat.countdown(preset)) },
|
||||
trailingIcon = {
|
||||
// An `IconButton`, not a `clickable` icon: removing a
|
||||
// preset is destructive and it sits inside the chip's own
|
||||
// click area, so it needs the platform's 48 dp minimum
|
||||
// target and the Button role rather than a 24 dp square.
|
||||
// The chip grows to that height with it, which is the
|
||||
// right direction for a target the user must be able to
|
||||
// hit — or miss — deliberately.
|
||||
IconButton(onClick = { onRemovePreset(preset) }) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Close,
|
||||
contentDescription = remove,
|
||||
modifier = Modifier.size(InputChipDefaults.AvatarSize),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
if (canSave) {
|
||||
AssistChip(
|
||||
onClick = { onSavePreset(typed) },
|
||||
label = { Text(stringResource(R.string.timers_preset_save)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package de.jeanlucmakiola.clockula.ui.timers
|
||||
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
internal object TimersDefaults {
|
||||
/** The readout formats to whole seconds, so one second is its own resolution. */
|
||||
val ReadoutTick: Duration = 1.seconds
|
||||
|
||||
/** The band the FAB occupies: its 56 dp plus the 16 dp margin. */
|
||||
val FabBand: Dp = 72.dp
|
||||
|
||||
val ListBottomPadding: Dp = 8.dp
|
||||
|
||||
/** Comfortably past the 48 dp minimum: a keypad is aimed at without looking. */
|
||||
val KeypadKeySize: Dp = 64.dp
|
||||
}
|
||||
@@ -1,17 +1,165 @@
|
||||
package de.jeanlucmakiola.clockula.ui.timers
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Add
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
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 de.jeanlucmakiola.clockula.R
|
||||
import de.jeanlucmakiola.clockula.ui.shell.EmptyTabScreen
|
||||
import de.jeanlucmakiola.clockula.domain.timer.TimerDurationEntry
|
||||
import de.jeanlucmakiola.clockula.ui.shell.LocalLivePillInset
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||
import kotlin.time.Duration
|
||||
|
||||
/** A top-level tab: its title bar and its empty state. Creating a timer is M6's. */
|
||||
/**
|
||||
* The Timers tab: several timers at once, each with its own controls, and the
|
||||
* setup panel that starts a new one.
|
||||
*
|
||||
* On a **non-empty** list the FAB opens the panel as a `ModalBottomSheet`; on
|
||||
* an **empty** one the tab hosts the same panel inline, because that *is* the
|
||||
* empty state (D23). Deliberately not M5's create-then-navigate: an alarm is
|
||||
* live the moment it exists, so creating it first is honest, while a timer has
|
||||
* a natural commit point — "Start" — and a row created by an accidental FAB tap
|
||||
* would be litter.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TimersScreen(modifier: Modifier = Modifier) {
|
||||
EmptyTabScreen(
|
||||
fun TimersScreen(
|
||||
onEditTimer: (Long) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: TimersViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val pillInset = LocalLivePillInset.current
|
||||
val addTimer = stringResource(R.string.timers_set_title)
|
||||
|
||||
// Saveable, not remembered: the typed digits and the sheet's open flag then
|
||||
// survive rotation *and* process death, and the pure `TimerDurationEntry`
|
||||
// is derived from the saved digit string (D23).
|
||||
var digits by rememberSaveable { mutableStateOf("") }
|
||||
var sheetOpen by rememberSaveable { mutableStateOf(false) }
|
||||
val entry = TimerDurationEntry(digits)
|
||||
val sheetState = rememberModalBottomSheetState()
|
||||
|
||||
val loaded = state as? TimersUiState.Loaded
|
||||
val rows = loaded?.rows
|
||||
val presets = loaded?.presets.orEmpty()
|
||||
|
||||
fun start() {
|
||||
viewModel.onStartNew(entry.duration)
|
||||
digits = ""
|
||||
sheetOpen = false
|
||||
}
|
||||
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.tab_timers),
|
||||
message = stringResource(R.string.timers_empty),
|
||||
modifier = modifier,
|
||||
)
|
||||
scrollable = false,
|
||||
floatingActionButton = {
|
||||
// Only over a list: on an empty tab the panel is already on screen,
|
||||
// and a FAB that opens what is in front of you is noise.
|
||||
if (rows != null && rows.isNotEmpty()) {
|
||||
FloatingActionButton(
|
||||
onClick = { sheetOpen = true },
|
||||
// The pill is bottom-centre and the FAB bottom-end, so on a
|
||||
// narrow phone they would otherwise overlap (M5 D6).
|
||||
modifier = Modifier.padding(bottom = pillInset),
|
||||
) {
|
||||
Icon(Icons.Rounded.Add, contentDescription = addTimer)
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(
|
||||
top = TimersDefaults.ListBottomPadding,
|
||||
bottom = TimersDefaults.ListBottomPadding + TimersDefaults.FabBand + pillInset,
|
||||
),
|
||||
) {
|
||||
if (rows != null && rows.isEmpty()) {
|
||||
item {
|
||||
TimerSetupPanel(
|
||||
entry = entry,
|
||||
presets = presets,
|
||||
onDigit = { digits = entry.plusDigit(it).digits },
|
||||
onBackspace = { digits = entry.backspace().digits },
|
||||
onPreset = { digits = digitsFor(it) },
|
||||
onSavePreset = viewModel::onSavePreset,
|
||||
onRemovePreset = viewModel::onRemovePreset,
|
||||
onStart = ::start,
|
||||
modifier = Modifier.padding(vertical = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
itemsIndexed(rows.orEmpty(), key = { _, row -> row.id }) { index, row ->
|
||||
TimerRow(
|
||||
row = row,
|
||||
position = positionOf(index, rows.orEmpty().size),
|
||||
onOpen = { onEditTimer(row.id) },
|
||||
onStart = { viewModel.onStart(row.id) },
|
||||
onPause = { viewModel.onPause(row.id) },
|
||||
onReset = { viewModel.onReset(row.id) },
|
||||
onAddMinute = { viewModel.onAddMinute(row.id) },
|
||||
// A timer that finishes stays where it is (D22); the motion
|
||||
// is for a row arriving or leaving.
|
||||
modifier = animateItemMotion(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sheetOpen) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { sheetOpen = false },
|
||||
sheetState = sheetState,
|
||||
) {
|
||||
TimerSetupPanel(
|
||||
entry = entry,
|
||||
presets = presets,
|
||||
onDigit = { digits = entry.plusDigit(it).digits },
|
||||
onBackspace = { digits = entry.backspace().digits },
|
||||
onPreset = { digits = digitsFor(it) },
|
||||
onSavePreset = viewModel::onSavePreset,
|
||||
onRemovePreset = viewModel::onRemovePreset,
|
||||
onStart = ::start,
|
||||
modifier = Modifier.padding(bottom = 24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A preset fills the readout as if it had been typed, so the keypad and the
|
||||
* chips share one state and a chip can still be corrected by hand.
|
||||
*/
|
||||
private fun digitsFor(duration: Duration): String {
|
||||
val total = duration.inWholeSeconds
|
||||
val hours = total / 3_600
|
||||
val minutes = (total % 3_600) / 60
|
||||
val seconds = total % 60
|
||||
return buildString {
|
||||
append(hours.toString().padStart(2, '0'))
|
||||
append(minutes.toString().padStart(2, '0'))
|
||||
append(seconds.toString().padStart(2, '0'))
|
||||
}.trimStart('0')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package de.jeanlucmakiola.clockula.ui.timers
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.clockula.data.timers.TimerRepository
|
||||
import de.jeanlucmakiola.clockula.domain.TimerDraft
|
||||
import de.jeanlucmakiola.clockula.domain.timer.TimerPresets
|
||||
import de.jeanlucmakiola.clockula.timer.TimerEngine
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.Duration
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The Timers tab: what it shows, the setup panel's one Start, and the six
|
||||
* lifecycle verbs — every one of which goes through [TimerEngine], because each
|
||||
* can move the expiry slot or the service (D2).
|
||||
*/
|
||||
@HiltViewModel
|
||||
class TimersViewModel @Inject internal constructor(
|
||||
private val timers: TimerRepository,
|
||||
private val engine: TimerEngine,
|
||||
private val settings: SettingsPrefs,
|
||||
source: TimerListSource,
|
||||
) : ViewModel() {
|
||||
|
||||
/** `WhileSubscribed(5_000)`, seeded [TimersUiState.Loading]. */
|
||||
val state: StateFlow<TimersUiState> =
|
||||
combine(source.rows(), settings.timerPresets) { rows, presets ->
|
||||
TimersUiState.Loaded(rows, presets)
|
||||
}
|
||||
// `WhileSubscribed`, not `Eagerly`: the upstream is a one-second
|
||||
// ticker over every timer, and eager sharing would keep it running
|
||||
// with nobody looking — the editor on top, or the app in the
|
||||
// background. The timeout is what gives rotation the same resilience
|
||||
// eager sharing did (M5's lesson).
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(SUBSCRIPTION_GRACE_MS), TimersUiState.Loading)
|
||||
|
||||
/**
|
||||
* True from the tap until the timer exists. A double-tapped Start would
|
||||
* otherwise create **two** timers, and the second would be litter the user
|
||||
* has to find and delete (M5's other lesson, D23).
|
||||
*/
|
||||
private var creating: Boolean = false
|
||||
|
||||
/** Creates a timer of [duration] and starts it. Re-entrant taps create one timer (D23). */
|
||||
fun onStartNew(duration: Duration) {
|
||||
if (creating) return
|
||||
// Outside `ClockPrefs`' own clamp there is nothing honest to create: the
|
||||
// panel's Start is disabled there, so this is the stale-caller guard.
|
||||
if (duration !in TimerPresets.MIN..TimerPresets.MAX) return
|
||||
creating = true
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val id = timers.create(TimerDraft(duration = duration))
|
||||
// "Start" is the commit point, so the row is running the moment
|
||||
// it exists — which is what pressing Start asked for (D23).
|
||||
engine.start(id)
|
||||
} finally {
|
||||
creating = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onStart(timerId: Long) = launchOnEngine { engine.start(timerId) }
|
||||
|
||||
fun onPause(timerId: Long) = launchOnEngine { engine.pause(timerId) }
|
||||
|
||||
fun onReset(timerId: Long) = launchOnEngine { engine.reset(timerId) }
|
||||
|
||||
fun onAddMinute(timerId: Long) = launchOnEngine { engine.addTime(timerId) }
|
||||
|
||||
/** Through the engine, so a *ringing* timer cannot be deleted into a stuck ring (D2). */
|
||||
fun onDelete(timerId: Long) = launchOnEngine { engine.delete(timerId) }
|
||||
|
||||
fun onSavePreset(duration: Duration) {
|
||||
viewModelScope.launch { settings.addTimerPreset(duration) }
|
||||
}
|
||||
|
||||
fun onRemovePreset(duration: Duration) {
|
||||
viewModelScope.launch { settings.removeTimerPreset(duration) }
|
||||
}
|
||||
|
||||
private fun launchOnEngine(block: suspend () -> Unit) {
|
||||
viewModelScope.launch { block() }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** The window a rotation has to re-subscribe in before the ticker is dropped. */
|
||||
const val SUBSCRIPTION_GRACE_MS = 5_000L
|
||||
}
|
||||
}
|
||||
@@ -24,9 +24,47 @@
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- M6: the timers tab, its rows and the setup panel -->
|
||||
<string name="timers_set_title">Set timer</string>
|
||||
<string name="timers_setup_start">Start</string>
|
||||
<string name="timers_setup_range_hint">Between 1 second and 24 hours</string>
|
||||
<string name="timers_presets">Presets</string>
|
||||
<string name="timers_preset_save">Save</string>
|
||||
<string name="timers_preset_remove">Remove preset</string>
|
||||
<string name="timer_keypad_backspace">Delete a digit</string>
|
||||
<string name="timer_keypad_digit">Type %1$s</string>
|
||||
<string name="timer_start">Start</string>
|
||||
<string name="timer_pause">Pause</string>
|
||||
<string name="timer_resume">Resume</string>
|
||||
<string name="timer_reset">Reset</string>
|
||||
<string name="timer_stop">Stop</string>
|
||||
<string name="timer_add_minute">+1 min</string>
|
||||
<string name="timer_finished">Finished</string>
|
||||
<string name="timer_paused_at">Paused \u00b7 %1$s</string>
|
||||
<string name="timer_untitled">Timer</string>
|
||||
<string name="timer_row_progress">%1$s left</string>
|
||||
<string name="timer_row_open">Timer settings</string>
|
||||
|
||||
<!-- M6: the timer editor -->
|
||||
<string name="timer_editor_title">Timer</string>
|
||||
<string name="timer_editor_label">Label</string>
|
||||
<string name="timer_editor_duration">Length</string>
|
||||
<string name="timer_editor_duration_locked">Reset the timer to change its length</string>
|
||||
<string name="timer_editor_sound">Sound</string>
|
||||
<string name="timer_editor_delete">Delete timer</string>
|
||||
<string name="timer_editor_delete_confirm">Delete this timer?</string>
|
||||
|
||||
<!-- M6: the timer notification. One channel, one id, however many timers. -->
|
||||
<string name="channel_timers_name">Timers</string>
|
||||
<string name="channel_timers_description">The running countdown and the sound when a timer finishes. Turning this off does not silence a timer.</string>
|
||||
<string name="timer_notification_title">Timer</string>
|
||||
<plurals name="timer_more_active">
|
||||
<item quantity="one">+%1$d more timer</item>
|
||||
<item quantity="other">+%1$d more timers</item>
|
||||
</plurals>
|
||||
|
||||
<!-- M4: the live pill -->
|
||||
<string name="live_pill_pause">Pause</string>
|
||||
<string name="live_pill_resume">Resume</string>
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package de.jeanlucmakiola.clockula.testing
|
||||
|
||||
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.clockula.data.timers.TimerRepository
|
||||
import de.jeanlucmakiola.clockula.data.timers.TimerRingStateStore
|
||||
import de.jeanlucmakiola.clockula.timer.AlarmRingStatus
|
||||
import de.jeanlucmakiola.clockula.timer.TimerEngine
|
||||
import de.jeanlucmakiola.clockula.timer.TimerScheduler
|
||||
import de.jeanlucmakiola.clockula.timer.TimerServiceHandle
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import de.jeanlucmakiola.floret.prefs.PrefStore
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* The expiry slot, held as **the value it currently holds** rather than as a
|
||||
* call log: a test then asserts what AlarmManager would be holding, which is
|
||||
* the thing that wakes the device (M3's shape, M6 D30).
|
||||
*/
|
||||
class FakeTimerScheduler : TimerScheduler {
|
||||
|
||||
var expiryAt: Duration? = null
|
||||
private set
|
||||
|
||||
private val log = mutableListOf<Duration?>()
|
||||
|
||||
/** Every transition, in order — null for a cancel. */
|
||||
val scheduled: List<Duration?> get() = log.toList()
|
||||
|
||||
override fun scheduleExpiry(at: Duration) {
|
||||
expiryAt = at
|
||||
log += at
|
||||
}
|
||||
|
||||
override fun cancelExpiry() {
|
||||
expiryAt = null
|
||||
log += null
|
||||
}
|
||||
}
|
||||
|
||||
/** The foreground service's up/down transitions, in order — the order is the contract (D6). */
|
||||
class FakeTimerServiceHandle : TimerServiceHandle {
|
||||
|
||||
var active: Boolean = false
|
||||
private set
|
||||
|
||||
private val log = mutableListOf<Boolean>()
|
||||
|
||||
val transitions: List<Boolean> get() = log.toList()
|
||||
|
||||
override fun sync(active: Boolean) {
|
||||
this.active = active
|
||||
log += active
|
||||
}
|
||||
}
|
||||
|
||||
/** "Is an alarm ringing", as a settable boolean whose flow re-emits (D9). */
|
||||
class FakeAlarmRingStatus(ringing: Boolean = false) : AlarmRingStatus {
|
||||
|
||||
private val state = MutableStateFlow(ringing)
|
||||
|
||||
var ringing: Boolean
|
||||
get() = state.value
|
||||
set(value) {
|
||||
state.value = value
|
||||
}
|
||||
|
||||
override fun isRinging(): Flow<Boolean> = state
|
||||
|
||||
override suspend fun currentlyRinging(): Boolean = state.value
|
||||
}
|
||||
|
||||
/**
|
||||
* A real [de.jeanlucmakiola.clockula.timer.TimerEngine] over a caller-supplied
|
||||
* repository and an in-memory store — the pill's arrangement (D25), where the
|
||||
* repository is the one whose call log the older cases assert on.
|
||||
*/
|
||||
class TimerEngineSeams(
|
||||
val timers: TimerRepository,
|
||||
now: Instant = T0,
|
||||
uptime: Duration = Duration.ZERO,
|
||||
) {
|
||||
val store: PrefStore = PrefStore(InMemoryPreferencesDataStore())
|
||||
val settings: SettingsPrefs = SettingsPrefs(store)
|
||||
val ringState: TimerRingStateStore = TimerRingStateStore(store)
|
||||
val scheduler: FakeTimerScheduler = FakeTimerScheduler()
|
||||
val service: FakeTimerServiceHandle = FakeTimerServiceHandle()
|
||||
val alarmRing: FakeAlarmRingStatus = FakeAlarmRingStatus()
|
||||
val rebootRepair: RecordingRebootRepair = RecordingRebootRepair()
|
||||
val wallClock: FakeWallClock = FakeWallClock(now)
|
||||
val elapsed: FakeElapsedRealtimeClock = FakeElapsedRealtimeClock(uptime)
|
||||
|
||||
val engine: TimerEngine = TimerEngine(
|
||||
timers = timers,
|
||||
settings = settings,
|
||||
ringState = ringState,
|
||||
scheduler = scheduler,
|
||||
service = service,
|
||||
alarmRing = alarmRing,
|
||||
rebootRepair = rebootRepair,
|
||||
elapsed = elapsed,
|
||||
wall = wallClock,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package de.jeanlucmakiola.clockula.testing
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import de.jeanlucmakiola.clockula.data.prefs.BootStateStore
|
||||
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.clockula.data.timers.TimerMapper
|
||||
import de.jeanlucmakiola.clockula.data.timers.TimerRepository
|
||||
import de.jeanlucmakiola.clockula.data.timers.TimerRepositoryImpl
|
||||
import de.jeanlucmakiola.clockula.data.timers.TimerRingStateStore
|
||||
import de.jeanlucmakiola.clockula.domain.Timer
|
||||
import de.jeanlucmakiola.clockula.system.RebootRepair
|
||||
import de.jeanlucmakiola.clockula.timer.TimerEngine
|
||||
import de.jeanlucmakiola.floret.prefs.PrefStore
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import java.nio.file.Path
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* The **real** [TimerEngine] over the fake DAO, the three fake seams and a real
|
||||
* DataStore under a `@TempDir` — `AlarmEngineHarness`'s arrangement, for the
|
||||
* other engine. The reboot gate is the real [RebootRepair] over the real
|
||||
* repository, so "the second call of a boot does not repair again" is the
|
||||
* production gate and not a fake's counter.
|
||||
*/
|
||||
class TimerEngineHarness(
|
||||
tempDir: Path,
|
||||
storeScope: CoroutineScope,
|
||||
now: Instant = T0,
|
||||
uptime: Duration = 1_000.seconds,
|
||||
val timerDao: FakeTimerDao = FakeTimerDao(),
|
||||
) {
|
||||
val wallClock: FakeWallClock = FakeWallClock(now)
|
||||
val elapsed: FakeElapsedRealtimeClock = FakeElapsedRealtimeClock(uptime)
|
||||
val scheduler: FakeTimerScheduler = FakeTimerScheduler()
|
||||
val service: FakeTimerServiceHandle = FakeTimerServiceHandle()
|
||||
val alarmRing: FakeAlarmRingStatus = FakeAlarmRingStatus()
|
||||
val bootIds: FakeBootIdProvider = FakeBootIdProvider()
|
||||
val ticker: FakeTicker = FakeTicker()
|
||||
|
||||
val stateDao: FakeAlarmStateDao = FakeAlarmStateDao()
|
||||
val alarmScheduler: FakeAlarmScheduler = FakeAlarmScheduler()
|
||||
val ringCoordinator: FakeRingCoordinator = FakeRingCoordinator()
|
||||
|
||||
/**
|
||||
* Counted, so "the session anchor was persisted **once** across three reads"
|
||||
* is assertable without reaching into the store (D15). Nothing else in an
|
||||
* engine pass writes a preference, so a count here is the anchor's.
|
||||
*/
|
||||
val dataStore: CountingPreferencesDataStore = CountingPreferencesDataStore(
|
||||
PreferenceDataStoreFactory.create(
|
||||
scope = storeScope,
|
||||
produceFile = { tempDir.resolve("clockula_prefs_test.preferences_pb").toFile() },
|
||||
),
|
||||
)
|
||||
|
||||
val store: PrefStore = PrefStore(dataStore)
|
||||
|
||||
val settings: SettingsPrefs = SettingsPrefs(store)
|
||||
val ringState: TimerRingStateStore = TimerRingStateStore(store)
|
||||
val timers: TimerRepository = TimerRepositoryImpl(timerDao, elapsed, wallClock)
|
||||
val stopwatch: RecordingStopwatchRepository = RecordingStopwatchRepository()
|
||||
|
||||
val rebootRepair: RebootRepair = RebootRepair(
|
||||
bootIds = bootIds,
|
||||
bootState = BootStateStore(store),
|
||||
timers = timers,
|
||||
stopwatch = stopwatch,
|
||||
)
|
||||
|
||||
val engine: TimerEngine = TimerEngine(
|
||||
timers = timers,
|
||||
settings = settings,
|
||||
ringState = ringState,
|
||||
scheduler = scheduler,
|
||||
service = service,
|
||||
alarmRing = alarmRing,
|
||||
rebootRepair = rebootRepair,
|
||||
elapsed = elapsed,
|
||||
wall = wallClock,
|
||||
)
|
||||
|
||||
/** Inserts a timer straight into storage, bypassing the engine. */
|
||||
suspend fun given(timer: Timer): Timer {
|
||||
timerDao.insert(TimerMapper.toEntity(timer))
|
||||
return timer
|
||||
}
|
||||
|
||||
/** What the table holds for [id] right now, read through the mapper. */
|
||||
suspend fun stored(id: Long): Timer? = timers.find(id)
|
||||
}
|
||||
|
||||
/** A [DataStore] that counts the transactions taken against it. */
|
||||
class CountingPreferencesDataStore(
|
||||
private val delegate: DataStore<Preferences>,
|
||||
) : DataStore<Preferences> {
|
||||
|
||||
var writes: Int = 0
|
||||
private set
|
||||
|
||||
override val data: Flow<Preferences> get() = delegate.data
|
||||
|
||||
override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences {
|
||||
writes++
|
||||
return delegate.updateData(transform)
|
||||
}
|
||||
}
|
||||
|
||||
/** The harness on `runTest`'s own scheduler, so nothing waits on a real clock. */
|
||||
fun TestScope.timerEngineHarness(
|
||||
tempDir: Path,
|
||||
now: Instant = T0,
|
||||
uptime: Duration = 1_000.seconds,
|
||||
timerDao: FakeTimerDao = FakeTimerDao(),
|
||||
): TimerEngineHarness = TimerEngineHarness(
|
||||
tempDir = tempDir,
|
||||
storeScope = CoroutineScope(UnconfinedTestDispatcher(testScheduler) + Job()),
|
||||
now = now,
|
||||
uptime = uptime,
|
||||
timerDao = timerDao,
|
||||
)
|
||||
@@ -0,0 +1,387 @@
|
||||
package de.jeanlucmakiola.clockula.ui.timers
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.clockula.domain.Ringtones
|
||||
import de.jeanlucmakiola.clockula.domain.TimerState
|
||||
import de.jeanlucmakiola.clockula.domain.timer.TimerMode
|
||||
import de.jeanlucmakiola.clockula.testing.FakeRingtoneCatalog
|
||||
import de.jeanlucmakiola.clockula.testing.FakeRingtonePreviewer
|
||||
import de.jeanlucmakiola.clockula.testing.MainDispatcherExtension
|
||||
import de.jeanlucmakiola.clockula.testing.PreviewEvent
|
||||
import de.jeanlucmakiola.clockula.testing.T0
|
||||
import de.jeanlucmakiola.clockula.testing.TimerEngineHarness
|
||||
import de.jeanlucmakiola.clockula.testing.expiredTimer
|
||||
import de.jeanlucmakiola.clockula.testing.idleTimer
|
||||
import de.jeanlucmakiola.clockula.testing.pausedTimer
|
||||
import de.jeanlucmakiola.clockula.testing.runningTimerWith
|
||||
import de.jeanlucmakiola.clockula.testing.timerEngineHarness
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.extension.RegisterExtension
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.nio.file.Path
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* §5.13 — 19 cases. The timer editor writes as the user goes, exactly as the
|
||||
* alarm editor does (M5 D22/D24), and it owns exactly one override: the
|
||||
* ringtone, through M5's picker (D27). Deleting goes through the engine, so a
|
||||
* *ringing* timer cannot be deleted into a stuck ring (D2).
|
||||
*/
|
||||
class TimerEditorViewModelTest {
|
||||
|
||||
@JvmField
|
||||
@RegisterExtension
|
||||
val main = MainDispatcherExtension()
|
||||
|
||||
private val catalog = FakeRingtoneCatalog()
|
||||
private val previewer = FakeRingtonePreviewer()
|
||||
private val uptime: Duration = 1_000.seconds
|
||||
|
||||
private fun TestScope.editor(harness: TimerEngineHarness, timerId: Long = 1L) =
|
||||
TimerEditorViewModel(
|
||||
savedStateHandle = SavedStateHandle(mapOf(TimerRoutes.ARG_TIMER_ID to timerId)),
|
||||
timers = harness.timers,
|
||||
engine = harness.engine,
|
||||
settings = harness.settings,
|
||||
ringtones = catalog,
|
||||
preview = previewer,
|
||||
appScope = this,
|
||||
)
|
||||
|
||||
private fun TimerEditorViewModel.editing(): TimerEditorUiState.Editing =
|
||||
state.value as TimerEditorUiState.Editing
|
||||
|
||||
/** §5.13 #1 */
|
||||
@Test
|
||||
fun `an id no timer has is already gone`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
|
||||
val harness = timerEngineHarness(tempDir)
|
||||
|
||||
val viewModel = editor(harness, timerId = 404L)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(viewModel.state.value).isEqualTo(TimerEditorUiState.Gone)
|
||||
}
|
||||
|
||||
/** §5.13 #2 */
|
||||
@Test
|
||||
fun `rapid typing stores the last keystroke, in order`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
val harness = timerEngineHarness(tempDir)
|
||||
harness.given(idleTimer(id = 1L, label = ""))
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onLabelTyped("P")
|
||||
viewModel.onLabelTyped("Pa")
|
||||
viewModel.onLabelTyped("Pas")
|
||||
viewModel.onLabelTyped("Pasta")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(harness.timerDao.stored.single().label).isEqualTo("Pasta")
|
||||
}
|
||||
|
||||
/** §5.13 #3 */
|
||||
@Test
|
||||
fun `leaving the editor does not lose the last keystroke`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
val harness = timerEngineHarness(tempDir)
|
||||
harness.given(idleTimer(id = 1L, label = ""))
|
||||
val viewModel = editor(harness)
|
||||
|
||||
viewModel.onLabelTyped("Pasta")
|
||||
viewModel.onCleared()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(harness.timerDao.stored.single().label).isEqualTo("Pasta")
|
||||
}
|
||||
|
||||
/** §5.13 #4 */
|
||||
@Test
|
||||
fun `only an idle timer's length may be edited`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
|
||||
val rows = listOf(
|
||||
idleTimer(id = 1L),
|
||||
runningTimerWith(id = 1L, left = 4.minutes, elapsedNow = uptime, wallNow = T0),
|
||||
pausedTimer(id = 1L, remaining = 2.minutes),
|
||||
expiredTimer(id = 1L),
|
||||
)
|
||||
|
||||
val answers = rows.map { row ->
|
||||
val harness = timerEngineHarness(tempDir.resolve(row.state.name).also { it.toFile().mkdirs() })
|
||||
harness.given(row)
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
viewModel.editing().mode to viewModel.editing().canEditDuration
|
||||
}
|
||||
|
||||
assertThat(answers).containsExactly(
|
||||
TimerMode.IDLE to true,
|
||||
TimerMode.RUNNING to false,
|
||||
TimerMode.PAUSED to false,
|
||||
TimerMode.EXPIRED to false,
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
/** §5.13 #5 */
|
||||
@Test
|
||||
fun `committing a duration on an idle timer moves its remaining with it`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(main.dispatcher) {
|
||||
val harness = timerEngineHarness(tempDir)
|
||||
harness.given(idleTimer(id = 1L, duration = 5.minutes))
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onDurationCommitted(90.seconds)
|
||||
advanceUntilIdle()
|
||||
|
||||
val stored = harness.stored(1L)!!
|
||||
assertThat(stored.duration to stored.remaining).isEqualTo(90.seconds to 90.seconds)
|
||||
}
|
||||
|
||||
/** §5.13 #6 */
|
||||
@Test
|
||||
fun `committing a duration on a running timer changes nothing`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = timerEngineHarness(tempDir)
|
||||
harness.given(runningTimerWith(id = 1L, left = 4.minutes, elapsedNow = uptime, wallNow = T0))
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
val before = harness.timerDao.stored
|
||||
|
||||
viewModel.onDurationCommitted(90.seconds)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(harness.timerDao.stored).isEqualTo(before)
|
||||
}
|
||||
|
||||
/** §5.13 #7 */
|
||||
@Test
|
||||
fun `a timer with no sound of its own inherits the app default`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = timerEngineHarness(tempDir)
|
||||
harness.settings.setTimerRingtoneUri("content://media/2")
|
||||
harness.given(idleTimer(id = 1L, ringtoneUri = null))
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
val ringtone = viewModel.editing().ringtone
|
||||
assertThat(ringtone.inherited to ringtone.effectiveUri)
|
||||
.isEqualTo(true to "content://media/2")
|
||||
}
|
||||
|
||||
/** §5.13 #8 */
|
||||
@Test
|
||||
fun `a sound, then the app default, then silence all store what they say`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(main.dispatcher) {
|
||||
val harness = timerEngineHarness(tempDir)
|
||||
harness.given(idleTimer(id = 1L))
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
val stored = listOf("content://media/1", null, Ringtones.SILENT_URI).map { choice ->
|
||||
viewModel.onRingtoneChosen(choice)
|
||||
advanceUntilIdle()
|
||||
harness.stored(1L)!!.ringtoneUri
|
||||
}
|
||||
|
||||
assertThat(stored).containsExactly("content://media/1", null, Ringtones.SILENT_URI).inOrder()
|
||||
}
|
||||
|
||||
/** §5.13 #9 */
|
||||
@Test
|
||||
fun `an unreadable sound is reported, never rewritten`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = timerEngineHarness(tempDir)
|
||||
catalog.unplayable = setOf("content://media/1")
|
||||
harness.given(idleTimer(id = 1L, ringtoneUri = "content://media/1"))
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(
|
||||
viewModel.editing().ringtone.unavailable to harness.stored(1L)!!.ringtoneUri,
|
||||
).isEqualTo(true to "content://media/1")
|
||||
}
|
||||
|
||||
/** §5.13 #10 */
|
||||
@Test
|
||||
fun `typing waits on no provider round-trip`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
|
||||
val harness = timerEngineHarness(tempDir)
|
||||
harness.given(idleTimer(id = 1L, label = "P", ringtoneUri = "content://media/1"))
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
check(viewModel.editing().ringtone.title == "Argon")
|
||||
val queriesAfterLoad = catalog.metadataCalls
|
||||
|
||||
repeat(12) { viewModel.onLabelTyped("P".repeat(it + 1)) }
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(catalog.metadataCalls).isEqualTo(queriesAfterLoad)
|
||||
}
|
||||
|
||||
/** §5.13 #11 */
|
||||
@Test
|
||||
fun `opening the picker loads the device's tones once`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = timerEngineHarness(tempDir)
|
||||
harness.given(idleTimer(id = 1L))
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onOpenRingtonePicker()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(catalog.alarmTonesCalls).isEqualTo(1)
|
||||
}
|
||||
|
||||
/** §5.13 #12 */
|
||||
@Test
|
||||
fun `closing the picker stops the preview`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
|
||||
val harness = timerEngineHarness(tempDir)
|
||||
harness.given(idleTimer(id = 1L))
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
viewModel.onPreview("content://media/1")
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onCloseRingtonePicker()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(previewer.events.last()).isEqualTo(PreviewEvent.Stopped)
|
||||
}
|
||||
|
||||
/** §5.13 #13 */
|
||||
@Test
|
||||
fun `leaving the editor stops the preview`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
|
||||
val harness = timerEngineHarness(tempDir)
|
||||
harness.given(idleTimer(id = 1L))
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
viewModel.onPreview("content://media/1")
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onCleared()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(previewer.events.last()).isEqualTo(PreviewEvent.Stopped)
|
||||
}
|
||||
|
||||
/** §5.13 #14 */
|
||||
@Test
|
||||
fun `a second preview stops the first before it plays`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = timerEngineHarness(tempDir)
|
||||
harness.given(idleTimer(id = 1L))
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onPreview("content://media/1")
|
||||
advanceUntilIdle()
|
||||
viewModel.onPreview("content://media/2")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(previewer.events).containsExactly(
|
||||
PreviewEvent.Played("content://media/1"),
|
||||
PreviewEvent.Stopped,
|
||||
PreviewEvent.Played("content://media/2"),
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
/** §5.13 #15 */
|
||||
@Test
|
||||
fun `previewing nothing or silence only stops`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
|
||||
val harness = timerEngineHarness(tempDir)
|
||||
harness.given(idleTimer(id = 1L))
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onPreview(null)
|
||||
viewModel.onPreview(Ringtones.SILENT_URI)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(previewer.events.filterIsInstance<PreviewEvent.Played>()).isEmpty()
|
||||
assertThat(previewer.events).isNotEmpty()
|
||||
}
|
||||
|
||||
/** §5.13 #16 */
|
||||
@Test
|
||||
fun `deleting takes the timer, the state and the ring session with it`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(main.dispatcher) {
|
||||
val harness = timerEngineHarness(tempDir)
|
||||
harness.given(expiredTimer(id = 1L))
|
||||
harness.ringState.set(T0 - 30.seconds)
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onDelete()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(harness.timerDao.stored).isEmpty()
|
||||
assertThat(viewModel.state.value to harness.ringState.current())
|
||||
.isEqualTo(TimerEditorUiState.Gone to null)
|
||||
}
|
||||
|
||||
/** §5.13 #17 */
|
||||
@Test
|
||||
fun `deleting a running timer cancels its expiry slot`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = timerEngineHarness(tempDir)
|
||||
harness.given(runningTimerWith(id = 1L, left = 4.minutes, elapsedNow = uptime, wallNow = T0))
|
||||
harness.engine.resync()
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onDelete()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(harness.scheduler.expiryAt).isNull()
|
||||
}
|
||||
|
||||
/** §5.13 #18 */
|
||||
@Test
|
||||
fun `a grant that cannot be made persistent is disclosed, not refused`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = timerEngineHarness(tempDir)
|
||||
catalog.canPersist = false
|
||||
harness.given(idleTimer(id = 1L))
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onDocumentPicked("content://docs/a")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(
|
||||
harness.stored(1L)!!.ringtoneUri to viewModel.editing().ringtone.accessMayLapse,
|
||||
).isEqualTo("content://docs/a" to true)
|
||||
}
|
||||
|
||||
/** §5.13 #19 */
|
||||
@Test
|
||||
fun `a keystroke racing the expiry write loses neither`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
val harness = timerEngineHarness(tempDir)
|
||||
harness.given(runningTimerWith(id = 1L, left = 1.seconds, elapsedNow = uptime - 1.minutes))
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onLabelTyped("Pasta")
|
||||
harness.engine.onExpiryDue()
|
||||
advanceUntilIdle()
|
||||
|
||||
val stored = harness.stored(1L)!!
|
||||
assertThat(stored.label to stored.state).isEqualTo("Pasta" to TimerState.EXPIRED)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package de.jeanlucmakiola.clockula.ui.timers
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.clockula.domain.Timer
|
||||
import de.jeanlucmakiola.clockula.domain.timer.TimerMode
|
||||
import de.jeanlucmakiola.clockula.testing.T0
|
||||
import de.jeanlucmakiola.clockula.testing.expiredTimer
|
||||
import de.jeanlucmakiola.clockula.testing.idleTimer
|
||||
import de.jeanlucmakiola.clockula.testing.pausedTimer
|
||||
import de.jeanlucmakiola.clockula.testing.runningTimerWith
|
||||
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
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* §5.11 — 11 cases. The tab is a list in **storage order**, never in subject
|
||||
* order (D22): an expired timer does not jump to the top, because a row that
|
||||
* moves under a thumb is worse than a row the user has to look for.
|
||||
*/
|
||||
class TimerListRowsTest {
|
||||
|
||||
private val now: Duration = 1_000.seconds
|
||||
private val wall: Instant = T0
|
||||
|
||||
private fun rows(
|
||||
timers: List<Timer>,
|
||||
elapsedRealtime: Duration = now,
|
||||
wallClock: Instant = wall,
|
||||
): List<TimerRowState> = TimerListRows.from(timers, elapsedRealtime, wallClock)
|
||||
|
||||
/** §5.11 #1 */
|
||||
@Test
|
||||
fun `no timers is no rows`() {
|
||||
assertThat(rows(emptyList())).isEmpty()
|
||||
}
|
||||
|
||||
/** §5.11 #2 */
|
||||
@Test
|
||||
fun `the rows come back in storage order`() {
|
||||
val result = rows(
|
||||
listOf(
|
||||
pausedTimer(id = 1L, sortOrder = 2),
|
||||
runningTimerWith(id = 2L, left = 1.minutes, elapsedNow = now, sortOrder = 0),
|
||||
idleTimer(id = 3L, sortOrder = 1),
|
||||
),
|
||||
)
|
||||
|
||||
assertThat(result.map { it.id }).containsExactly(2L, 3L, 1L).inOrder()
|
||||
}
|
||||
|
||||
/** §5.11 #3 */
|
||||
@Test
|
||||
fun `an expired timer does not jump to the top`() {
|
||||
val result = rows(
|
||||
listOf(
|
||||
runningTimerWith(id = 1L, left = 1.minutes, elapsedNow = now, sortOrder = 0),
|
||||
expiredTimer(id = 2L, sortOrder = 2),
|
||||
),
|
||||
)
|
||||
|
||||
assertThat(result.map { it.id to it.mode })
|
||||
.containsExactly(1L to TimerMode.RUNNING, 2L to TimerMode.EXPIRED)
|
||||
.inOrder()
|
||||
}
|
||||
|
||||
/** §5.11 #4 */
|
||||
@Test
|
||||
fun `an idle timer reads its configured length, full`() {
|
||||
val row = rows(listOf(idleTimer(duration = 5.minutes))).single()
|
||||
|
||||
assertThat(row.remaining to row.progress).isEqualTo(5.minutes to 1f)
|
||||
}
|
||||
|
||||
/** §5.11 #5 */
|
||||
@Test
|
||||
fun `a running timer halfway through is half full`() {
|
||||
val timer = runningTimerWith(left = 150.seconds, elapsedNow = now, duration = 5.minutes)
|
||||
|
||||
assertThat(rows(listOf(timer)).single().progress).isEqualTo(0.5f)
|
||||
}
|
||||
|
||||
/** §5.11 #6 */
|
||||
@Test
|
||||
fun `a paused timer reads its banked remainder`() {
|
||||
val timer = pausedTimer(remaining = 60.seconds, duration = 5.minutes)
|
||||
|
||||
val row = rows(listOf(timer)).single()
|
||||
|
||||
assertThat(row.remaining to row.progress).isEqualTo(60.seconds to 0.2f)
|
||||
}
|
||||
|
||||
/** §5.11 #7 */
|
||||
@Test
|
||||
fun `an expired timer reads empty`() {
|
||||
val row = rows(listOf(expiredTimer(duration = 5.minutes))).single()
|
||||
|
||||
assertThat(row.remaining to row.progress).isEqualTo(Duration.ZERO to 0f)
|
||||
}
|
||||
|
||||
/** §5.11 #8 */
|
||||
@Test
|
||||
fun `a zero-length timer divides by nothing`() {
|
||||
val row = rows(listOf(idleTimer(duration = Duration.ZERO))).single()
|
||||
|
||||
assertThat(row.progress).isEqualTo(0f)
|
||||
}
|
||||
|
||||
/** §5.11 #9 */
|
||||
@Test
|
||||
fun `a timer with more left than its length is clamped full`() {
|
||||
val timer = runningTimerWith(left = 10.minutes, elapsedNow = now, duration = 5.minutes)
|
||||
|
||||
assertThat(rows(listOf(timer)).single().progress).isEqualTo(1f)
|
||||
}
|
||||
|
||||
/** §5.11 #10 */
|
||||
@Test
|
||||
fun `a blank label stays blank, because the composable owns the fallback`() {
|
||||
val row = rows(listOf(idleTimer(label = ""))).single()
|
||||
|
||||
assertThat(row.label).isEmpty()
|
||||
}
|
||||
|
||||
/** §5.11 #11 */
|
||||
@Test
|
||||
fun `three hours of clock jump each way changes no readout`() {
|
||||
val timers = listOf(runningTimerWith(left = 150.seconds, elapsedNow = now, wallNow = wall))
|
||||
val before = rows(timers)
|
||||
|
||||
val forward = rows(timers, wallClock = wall + 3.hours)
|
||||
val backward = rows(timers, wallClock = wall - 3.hours)
|
||||
|
||||
assertThat(listOf(forward, backward)).containsExactly(before, before)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package de.jeanlucmakiola.clockula.ui.timers
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* §5.16 — 3 cases. The editor is a sibling route in the same flat `NavHost`, so
|
||||
* M4's navigation policy already answers what the tab bar shows and who owns
|
||||
* back; those two properties are §5.15's.
|
||||
*/
|
||||
class TimerRoutesTest {
|
||||
|
||||
/** §5.16 #1 */
|
||||
@Test
|
||||
fun `the editor route carries the timer's id`() {
|
||||
assertThat(TimerRoutes.editor(7L)).isEqualTo("timers/edit/7")
|
||||
}
|
||||
|
||||
/** §5.16 #2 */
|
||||
@Test
|
||||
fun `the pattern's placeholder is the declared argument name`() {
|
||||
assertThat(TimerRoutes.EDITOR_PATTERN).contains("{" + TimerRoutes.ARG_TIMER_ID + "}")
|
||||
}
|
||||
|
||||
/** §5.16 #3 */
|
||||
@Test
|
||||
fun `a negative id is a route, not a crash`() {
|
||||
val prefix = TimerRoutes.EDITOR_PATTERN.substringBefore("{")
|
||||
|
||||
assertThat(TimerRoutes.editor(-1L)).isEqualTo(prefix + "-1")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package de.jeanlucmakiola.clockula.ui.timers
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.clockula.data.timers.TimerEntity
|
||||
import de.jeanlucmakiola.clockula.domain.TimerState
|
||||
import de.jeanlucmakiola.clockula.testing.FakeTimerDao
|
||||
import de.jeanlucmakiola.clockula.testing.T0
|
||||
import de.jeanlucmakiola.clockula.testing.TimerEngineHarness
|
||||
import de.jeanlucmakiola.clockula.testing.expiredTimer
|
||||
import de.jeanlucmakiola.clockula.testing.idleTimer
|
||||
import de.jeanlucmakiola.clockula.testing.runningTimerWith
|
||||
import de.jeanlucmakiola.clockula.testing.timerEngineHarness
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import de.jeanlucmakiola.clockula.testing.MainDispatcherExtension
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.extension.RegisterExtension
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.nio.file.Path
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* §5.12 — 12 cases over the real repository and a real `TimerEngine`. The six
|
||||
* verbs go through the engine, not the repository, which is what keeps the
|
||||
* expiry slot and the service moving with the state (D2) — and the expiry slot
|
||||
* is how a test proves the call took that route.
|
||||
*/
|
||||
class TimersViewModelTest {
|
||||
|
||||
@JvmField
|
||||
@RegisterExtension
|
||||
val main = MainDispatcherExtension()
|
||||
|
||||
private val uptime: Duration = 1_000.seconds
|
||||
|
||||
private val dao = SubscriptionCountingTimerDao()
|
||||
|
||||
private fun TestScope.viewModel(harness: TimerEngineHarness) = TimersViewModel(
|
||||
timers = harness.timers,
|
||||
engine = harness.engine,
|
||||
settings = harness.settings,
|
||||
source = TimerListSource(harness.timers, harness.elapsed, harness.wallClock, harness.ticker),
|
||||
).also { viewModel -> backgroundScope.launch { viewModel.state.collect {} } }
|
||||
|
||||
private fun TimersUiState.loaded(): TimersUiState.Loaded = this as TimersUiState.Loaded
|
||||
|
||||
/** §5.12 #1 */
|
||||
@Test
|
||||
fun `the list starts out loading, not empty`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
val harness = timerEngineHarness(tempDir, timerDao = dao)
|
||||
|
||||
val viewModel = viewModel(harness)
|
||||
|
||||
assertThat(viewModel.state.value).isEqualTo(TimersUiState.Loading)
|
||||
}
|
||||
|
||||
/** §5.12 #2 */
|
||||
@Test
|
||||
fun `a timer stored underneath the list appears in it`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = timerEngineHarness(tempDir, timerDao = dao)
|
||||
val viewModel = viewModel(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
harness.given(idleTimer(id = 1L, label = "Pasta"))
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(viewModel.state.value.loaded().rows.map { it.id }).containsExactly(1L)
|
||||
}
|
||||
|
||||
/** §5.12 #3 */
|
||||
@Test
|
||||
fun `starting a new timer creates it, runs it and registers its deadline`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(main.dispatcher) {
|
||||
val harness = timerEngineHarness(tempDir, timerDao = dao)
|
||||
val viewModel = viewModel(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onStartNew(90.seconds)
|
||||
advanceUntilIdle()
|
||||
|
||||
val stored = harness.stored(dao.stored.single().id)!!
|
||||
assertThat(listOf(stored.state, stored.duration, harness.scheduler.expiryAt))
|
||||
.containsExactly(TimerState.RUNNING, 90.seconds, uptime + 90.seconds)
|
||||
.inOrder()
|
||||
}
|
||||
|
||||
/** §5.12 #4 */
|
||||
@Test
|
||||
fun `a double-tapped start creates one timer`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
val harness = timerEngineHarness(tempDir, timerDao = dao)
|
||||
val viewModel = viewModel(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onStartNew(90.seconds)
|
||||
viewModel.onStartNew(90.seconds)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(dao.stored).hasSize(1)
|
||||
}
|
||||
|
||||
/** §5.12 #5 */
|
||||
@Test
|
||||
fun `a duration outside the allowed range creates nothing`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = timerEngineHarness(tempDir, timerDao = dao)
|
||||
val viewModel = viewModel(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onStartNew(500.milliseconds)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(dao.stored).isEmpty()
|
||||
}
|
||||
|
||||
/** §5.12 #6 */
|
||||
@Test
|
||||
fun `pausing from the list cancels the expiry slot`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = timerEngineHarness(tempDir, timerDao = dao)
|
||||
harness.given(runningTimerWith(id = 1L, left = 4.minutes, elapsedNow = uptime, wallNow = T0))
|
||||
harness.engine.resync()
|
||||
val viewModel = viewModel(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onPause(1L)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(harness.scheduler.expiryAt).isNull()
|
||||
}
|
||||
|
||||
/** §5.12 #7 */
|
||||
@Test
|
||||
fun `deleting a sounding timer closes the ring session`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = timerEngineHarness(tempDir, timerDao = dao)
|
||||
harness.given(expiredTimer(id = 1L))
|
||||
harness.ringState.set(T0 - 30.seconds)
|
||||
val viewModel = viewModel(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onDelete(1L)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(harness.ringState.current()).isNull()
|
||||
}
|
||||
|
||||
/** §5.12 #8 */
|
||||
@Test
|
||||
fun `the stored presets are part of the loaded state`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = timerEngineHarness(tempDir, timerDao = dao)
|
||||
harness.settings.setTimerPresets(listOf(2.minutes, 20.minutes))
|
||||
val viewModel = viewModel(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(viewModel.state.value.loaded().presets)
|
||||
.containsExactly(2.minutes, 20.minutes)
|
||||
.inOrder()
|
||||
}
|
||||
|
||||
/** §5.12 #9 */
|
||||
@Test
|
||||
fun `saving a preset keeps the set ascending`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
|
||||
val harness = timerEngineHarness(tempDir, timerDao = dao)
|
||||
harness.settings.setTimerPresets(listOf(1.minutes, 5.minutes))
|
||||
val viewModel = viewModel(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onSavePreset(3.minutes)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(viewModel.state.value.loaded().presets)
|
||||
.containsExactly(1.minutes, 3.minutes, 5.minutes)
|
||||
.inOrder()
|
||||
}
|
||||
|
||||
/** §5.12 #10 */
|
||||
@Test
|
||||
fun `saving a preset that is already there changes nothing`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = timerEngineHarness(tempDir, timerDao = dao)
|
||||
harness.settings.setTimerPresets(listOf(1.minutes, 5.minutes))
|
||||
val viewModel = viewModel(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onSavePreset(5.minutes)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(viewModel.state.value.loaded().presets)
|
||||
.containsExactly(1.minutes, 5.minutes)
|
||||
.inOrder()
|
||||
}
|
||||
|
||||
/** §5.12 #11 */
|
||||
@Test
|
||||
fun `removing a preset leaves the rest`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
|
||||
val harness = timerEngineHarness(tempDir, timerDao = dao)
|
||||
harness.settings.setTimerPresets(listOf(1.minutes, 5.minutes, 10.minutes))
|
||||
val viewModel = viewModel(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onRemovePreset(5.minutes)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(viewModel.state.value.loaded().presets)
|
||||
.containsExactly(1.minutes, 10.minutes)
|
||||
.inOrder()
|
||||
}
|
||||
|
||||
/** §5.12 #12 */
|
||||
@Test
|
||||
fun `two collectors subscribe the repository once`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = timerEngineHarness(tempDir, timerDao = dao)
|
||||
val viewModel = viewModel(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
backgroundScope.launch { viewModel.state.collect {} }
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(dao.subscriptions).isEqualTo(1)
|
||||
}
|
||||
|
||||
/** Counts the collectors the observing flow actually acquires. */
|
||||
private class SubscriptionCountingTimerDao : FakeTimerDao() {
|
||||
var subscriptions: Int = 0
|
||||
private set
|
||||
|
||||
override fun observeAll(): Flow<List<TimerEntity>> =
|
||||
super.observeAll().onStart { subscriptions++ }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user