feat(alarms): the list and the editor
The tab the app has been missing. Rows carry the time, the repeat summary and the next-fire countdown, ordered by time of day rather than by when they fire next — a row must not move under the thumb reaching for it, so the next alarm is marked by colour instead. The editor covers time, repeat days, label, ringtone, vibration and snooze, with the five per-alarm overrides as three-state pickers: a switch cannot say "I have not chosen". Two things that look like details and are not. A ringing alarm is dismissed through the engine before it is disabled, edited or deleted — otherwise its state is cleared while the service keeps sounding and the auto-silence backstop returns early on its own guard, leaving the alarm ringing until the wake-lock timeout. And clearing the last repeat day disarms a pending skip, because otherwise the resolver disables the alarm and the control that would have explained it is already hidden. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wmy1BpCKi8KeSjaWhYuCPV
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
package de.jeanlucmakiola.clockula.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.text.format.DateFormat
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsOff
|
||||
import androidx.compose.ui.test.junit4.v2.createEmptyComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performTextReplacement
|
||||
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.alarms.AlarmRepository
|
||||
import de.jeanlucmakiola.clockula.data.alarms.AlarmStateRepository
|
||||
import de.jeanlucmakiola.clockula.domain.AlarmDraft
|
||||
import de.jeanlucmakiola.clockula.domain.TimeOfDay
|
||||
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 java.time.DayOfWeek
|
||||
import java.time.format.TextStyle
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* What only a device can answer about the alarms tab: that the row's switch,
|
||||
* the FAB, the M3 time picker, the day toggles, the ringtone picker and the
|
||||
* delete confirmation really compose and really write.
|
||||
*
|
||||
* Compiled in the gate; **not run here** — no device is attached (M5 §5.17).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class AlarmsInstrumentedTest {
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface Seams {
|
||||
fun alarms(): AlarmRepository
|
||||
fun states(): AlarmStateRepository
|
||||
}
|
||||
|
||||
@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.states().clearAll()
|
||||
seams.alarms().alarms().first().forEach { seams.alarms().delete(it.id) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun string(id: Int): String = context.getString(id)
|
||||
|
||||
/** The alarm's time as the device itself renders it, 12- or 24-hour. */
|
||||
private fun timeText(hour: Int, minute: Int): String {
|
||||
val calendar = Calendar.getInstance().apply {
|
||||
set(Calendar.HOUR_OF_DAY, hour)
|
||||
set(Calendar.MINUTE, minute)
|
||||
}
|
||||
return DateFormat.getTimeFormat(context).format(calendar.time)
|
||||
}
|
||||
|
||||
private fun givenAlarm(label: String = "Work"): Long = runBlocking {
|
||||
seams.alarms().create(AlarmDraft(time = TimeOfDay(7, 0), label = label))
|
||||
}
|
||||
|
||||
private fun stored(id: Long) = runBlocking { seams.alarms().find(id) }
|
||||
|
||||
private fun launch(): ActivityScenario<MainActivity> =
|
||||
ActivityScenario.launch<MainActivity>(
|
||||
Intent(context, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||
).also { scenario = it }
|
||||
|
||||
private fun openEditorForNewAlarm() {
|
||||
launch()
|
||||
composeTestRule.waitForIdle()
|
||||
composeTestRule.onNodeWithContentDescription(string(R.string.alarms_add)).performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tappingTheSwitchDisablesTheAlarm() {
|
||||
val id = givenAlarm()
|
||||
launch()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule.onNodeWithContentDescription(
|
||||
context.getString(R.string.alarm_row_toggle, timeText(7, 0)),
|
||||
).performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule.onNodeWithContentDescription(
|
||||
context.getString(R.string.alarm_row_toggle, timeText(7, 0)),
|
||||
).assertIsOff()
|
||||
assertThat(stored(id)!!.enabled).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theFabCreatesAnAlarmAndOpensItsEditor() {
|
||||
openEditorForNewAlarm()
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.editor_title)).assertIsDisplayed()
|
||||
assertThat(runBlocking { seams.alarms().alarms().first() }).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theTimePickerWritesTheTypedTime() {
|
||||
val id = givenAlarm()
|
||||
launch()
|
||||
composeTestRule.waitForIdle()
|
||||
composeTestRule.onNodeWithText("Work").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule.onNodeWithText(timeText(7, 0), substring = true).performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
composeTestRule.onNodeWithContentDescription("hour", substring = true)
|
||||
.performTextReplacement("06")
|
||||
composeTestRule.onNodeWithContentDescription("minute", substring = true)
|
||||
.performTextReplacement("45")
|
||||
composeTestRule.onNodeWithText(string(R.string.action_ok)).performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
assertThat(stored(id)!!.time).isEqualTo(TimeOfDay(6, 45))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun togglingADayReplacesTheOnceSummary() {
|
||||
val id = givenAlarm()
|
||||
launch()
|
||||
composeTestRule.waitForIdle()
|
||||
composeTestRule.onNodeWithText("Work").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule.onNodeWithContentDescription(
|
||||
DayOfWeek.TUESDAY.getDisplayName(TextStyle.FULL, Locale.getDefault()),
|
||||
).performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.alarm_repeat_once)).assertDoesNotExist()
|
||||
assertThat(DayOfWeek.TUESDAY in stored(id)!!.repeatDays).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun leavingTheRingtonePickerChangesNothing() {
|
||||
val id = givenAlarm()
|
||||
launch()
|
||||
composeTestRule.waitForIdle()
|
||||
composeTestRule.onNodeWithText("Work").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.editor_ringtone)).performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
Espresso.pressBack()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.ringtone_picker_title)).assertDoesNotExist()
|
||||
assertThat(stored(id)!!.ringtoneUri).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun backFromTheEditorReturnsToTheAlarmsTab() {
|
||||
givenAlarm()
|
||||
launch()
|
||||
composeTestRule.waitForIdle()
|
||||
composeTestRule.onNodeWithText("Work").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule.onNodeWithContentDescription(
|
||||
string(de.jeanlucmakiola.floret.components.R.string.back),
|
||||
).performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.tab_alarms)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Work").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deletingFromTheEditorRemovesTheRow() {
|
||||
val id = givenAlarm()
|
||||
launch()
|
||||
composeTestRule.waitForIdle()
|
||||
composeTestRule.onNodeWithText("Work").performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.editor_delete)).performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
composeTestRule.onNodeWithText(string(R.string.action_delete)).performClick()
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
composeTestRule.onNodeWithText("Work").assertDoesNotExist()
|
||||
assertThat(stored(id)).isNull()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
package de.jeanlucmakiola.clockula.ui.alarms
|
||||
|
||||
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.Switch
|
||||
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.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.TimeOfDay
|
||||
import de.jeanlucmakiola.clockula.ui.common.rememberAlarmTimeFormatter
|
||||
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
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
|
||||
/** Which override picker is open. Null is "none"; the ringtone picker is the ViewModel's. */
|
||||
private enum class OpenPicker { VIBRATE, VOLUME_RAMP, SNOOZE_LENGTH, SNOOZE_LIMIT, CHALLENGE }
|
||||
|
||||
/**
|
||||
* The alarm editor: 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 AlarmEditorScreen(
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: AlarmEditorViewModel = 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 — and the file the user picked would be discarded in silence
|
||||
// (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 AlarmEditorUiState.Gone) onBack()
|
||||
}
|
||||
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.editor_title),
|
||||
onBack = onBack,
|
||||
modifier = modifier,
|
||||
predictiveBack = true,
|
||||
) {
|
||||
val editing = state as? AlarmEditorUiState.Editing ?: return@CollapsingScaffold
|
||||
AlarmEditorContent(editing = editing, viewModel = viewModel)
|
||||
}
|
||||
|
||||
picker?.let { pickerState ->
|
||||
val editing = state as? AlarmEditorUiState.Editing
|
||||
RingtonePickerScreen(
|
||||
state = pickerState,
|
||||
defaultUri = editing?.defaults?.alarmRingtoneUri,
|
||||
onChoose = viewModel::onRingtoneChosen,
|
||||
onPreview = viewModel::onPreview,
|
||||
onPickFile = { pickDocument.launch(arrayOf(AUDIO_MIME)) },
|
||||
onDismiss = viewModel::onCloseRingtonePicker,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private const val AUDIO_MIME = "audio/*"
|
||||
|
||||
@Composable
|
||||
private fun AlarmEditorContent(
|
||||
editing: AlarmEditorUiState.Editing,
|
||||
viewModel: AlarmEditorViewModel,
|
||||
) {
|
||||
// Saveable, not remembered: a dialog open across a rotation must still be
|
||||
// open afterwards — `AlarmTimePickerDialog` already saves its own display
|
||||
// mode, so remembering the flag that hosts it would throw the dialog away
|
||||
// and keep the mode (M5 D22's "back is just back" is about writes, not
|
||||
// about losing a half-entered time).
|
||||
var openPicker by rememberSaveable { mutableStateOf<OpenPicker?>(null) }
|
||||
var pickingTime by rememberSaveable { mutableStateOf(false) }
|
||||
var confirmingDelete by rememberSaveable { mutableStateOf(false) }
|
||||
val formatTime = rememberAlarmTimeFormatter()
|
||||
|
||||
TimeCard(time = editing.time, onClick = { pickingTime = true })
|
||||
LabelField(label = editing.label, onLabelTyped = viewModel::onLabelTyped)
|
||||
|
||||
SectionHeader(stringResource(R.string.editor_repeat))
|
||||
RepeatDaySelector(repeatDays = editing.repeatDays, onDayToggled = viewModel::onDayToggled)
|
||||
if (editing.canSkipNext) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.editor_skip_next),
|
||||
summary = stringResource(R.string.editor_skip_next_summary),
|
||||
position = Position.Alone,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = editing.skipNextOccurrence,
|
||||
onCheckedChange = viewModel::onSkipNextChanged,
|
||||
)
|
||||
},
|
||||
onClick = { viewModel.onSkipNextChanged(!editing.skipNextOccurrence) },
|
||||
)
|
||||
}
|
||||
|
||||
SectionHeader(stringResource(R.string.editor_section_sound))
|
||||
SoundRow(editing = editing, position = positionOf(0, 3), onClick = viewModel::onOpenRingtonePicker)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.editor_vibrate),
|
||||
summary = overrideSummary(editing.vibrate.inherited, vibrateText(editing.vibrate.value)),
|
||||
position = positionOf(1, 3),
|
||||
onClick = { openPicker = OpenPicker.VIBRATE },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.editor_volume_ramp),
|
||||
summary = overrideSummary(
|
||||
editing.volumeRampSeconds.inherited,
|
||||
volumeRampText(editing.volumeRampSeconds.value),
|
||||
),
|
||||
position = positionOf(2, 3),
|
||||
onClick = { openPicker = OpenPicker.VOLUME_RAMP },
|
||||
)
|
||||
|
||||
SectionHeader(stringResource(R.string.editor_section_snooze))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.editor_snooze_length),
|
||||
summary = overrideSummary(
|
||||
editing.snoozeMinutes.inherited,
|
||||
snoozeMinutesText(editing.snoozeMinutes.value),
|
||||
),
|
||||
position = positionOf(0, 3),
|
||||
onClick = { openPicker = OpenPicker.SNOOZE_LENGTH },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.editor_snooze_limit),
|
||||
summary = overrideSummary(
|
||||
editing.snoozeLimit.inherited,
|
||||
snoozeLimitText(editing.snoozeLimit.value),
|
||||
),
|
||||
position = positionOf(1, 3),
|
||||
onClick = { openPicker = OpenPicker.SNOOZE_LIMIT },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.editor_dismiss_challenge),
|
||||
summary = overrideSummary(
|
||||
editing.dismissChallenge.inherited,
|
||||
challengeText(editing.dismissChallenge.value),
|
||||
),
|
||||
position = positionOf(2, 3),
|
||||
onClick = { openPicker = OpenPicker.CHALLENGE },
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
DeleteRow(onClick = { confirmingDelete = true })
|
||||
Spacer(Modifier.height(LocalLivePillInset.current))
|
||||
|
||||
if (pickingTime) {
|
||||
AlarmTimePickerDialog(
|
||||
initial = editing.time,
|
||||
onConfirm = { hour, minute ->
|
||||
viewModel.onTimePicked(hour, minute)
|
||||
pickingTime = false
|
||||
},
|
||||
onDismiss = { pickingTime = false },
|
||||
)
|
||||
}
|
||||
if (confirmingDelete) {
|
||||
DeleteConfirmation(
|
||||
time = formatTime(editing.time),
|
||||
onConfirm = {
|
||||
confirmingDelete = false
|
||||
viewModel.onDelete()
|
||||
},
|
||||
onDismiss = { confirmingDelete = false },
|
||||
)
|
||||
}
|
||||
when (openPicker) {
|
||||
null -> Unit
|
||||
OpenPicker.VIBRATE -> VibratePicker(
|
||||
stored = editing.vibrate.value.takeUnless { editing.vibrate.inherited },
|
||||
default = editing.defaults.vibrate,
|
||||
onSelect = viewModel::onVibrateChanged,
|
||||
onDismiss = { openPicker = null },
|
||||
)
|
||||
OpenPicker.VOLUME_RAMP -> VolumeRampPicker(
|
||||
stored = editing.volumeRampSeconds.value.takeUnless { editing.volumeRampSeconds.inherited },
|
||||
default = editing.defaults.volumeRampSeconds,
|
||||
onSelect = viewModel::onVolumeRampChanged,
|
||||
onDismiss = { openPicker = null },
|
||||
)
|
||||
OpenPicker.SNOOZE_LENGTH -> SnoozeLengthPicker(
|
||||
stored = editing.snoozeMinutes.value.takeUnless { editing.snoozeMinutes.inherited },
|
||||
default = editing.defaults.snoozeMinutes,
|
||||
onSelect = viewModel::onSnoozeMinutesChanged,
|
||||
onDismiss = { openPicker = null },
|
||||
)
|
||||
OpenPicker.SNOOZE_LIMIT -> SnoozeLimitPicker(
|
||||
stored = editing.snoozeLimit.value.takeUnless { editing.snoozeLimit.inherited },
|
||||
default = editing.defaults.snoozeLimit,
|
||||
onSelect = viewModel::onSnoozeLimitChanged,
|
||||
onDismiss = { openPicker = null },
|
||||
)
|
||||
OpenPicker.CHALLENGE -> DismissChallengePicker(
|
||||
stored = editing.dismissChallenge.value.takeUnless { editing.dismissChallenge.inherited },
|
||||
default = editing.defaults.dismissChallenge,
|
||||
onSelect = viewModel::onDismissChallengeChanged,
|
||||
onDismiss = { openPicker = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The time itself, at display size — the one place in the app where it is the page. */
|
||||
@Composable
|
||||
private fun TimeCard(time: TimeOfDay, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
val formatTime = rememberAlarmTimeFormatter()
|
||||
GroupedSurface(
|
||||
position = Position.Alone,
|
||||
modifier = modifier.padding(horizontal = GroupedListInset),
|
||||
onClick = onClick,
|
||||
) {
|
||||
Text(
|
||||
text = formatTime(time),
|
||||
style = MaterialTheme.typography.displayMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@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.editor_label_placeholder),
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SoundRow(
|
||||
editing: AlarmEditorUiState.Editing,
|
||||
position: Position,
|
||||
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 ringtone = editing.ringtone
|
||||
val value = when {
|
||||
ringtone.silent -> silent
|
||||
ringtone.effectiveUri == null -> deviceDefault
|
||||
else -> ringtone.title ?: unknown
|
||||
}
|
||||
val disclosures = buildList {
|
||||
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.editor_ringtone),
|
||||
summary = (listOf(overrideSummary(ringtone.inherited, value)) + disclosures)
|
||||
.joinToString("\n"),
|
||||
position = position,
|
||||
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.editor_delete)
|
||||
val title: AnnotatedString = buildAnnotatedString {
|
||||
withStyle(SpanStyle(color = error)) { append(label) }
|
||||
}
|
||||
GroupedRow(title = title, position = Position.Alone, onClick = onClick)
|
||||
}
|
||||
|
||||
/**
|
||||
* A confirmation that names the alarm, deliberately instead of an undo chip:
|
||||
* an accidentally deleted alarm is discovered at 07:00, not now, so the cheap
|
||||
* moment to ask is before (M5 D29).
|
||||
*/
|
||||
@Composable
|
||||
private fun DeleteConfirmation(time: String, onConfirm: () -> Unit, onDismiss: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.editor_delete_title)) },
|
||||
text = { Text(stringResource(R.string.editor_delete_message, time)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = onConfirm) { Text(stringResource(R.string.action_delete)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** A run's header, on the same edge as the cards it belongs to. */
|
||||
@Composable
|
||||
private fun SectionHeader(text: String) {
|
||||
Column {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package de.jeanlucmakiola.clockula.ui.alarms
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.ClockDefaults
|
||||
import de.jeanlucmakiola.clockula.domain.DismissChallenge
|
||||
import de.jeanlucmakiola.clockula.domain.RepeatDays
|
||||
import de.jeanlucmakiola.clockula.domain.TimeOfDay
|
||||
|
||||
data class OverrideValue<T>(val value: T, val inherited: Boolean)
|
||||
|
||||
internal object AlarmOverrides {
|
||||
/** `inherited == (stored == null)`. A stored `false` or `0` is a choice, not an absence. */
|
||||
fun <T> of(stored: T?, default: T): OverrideValue<T> =
|
||||
OverrideValue(value = stored ?: default, inherited = stored == null)
|
||||
}
|
||||
|
||||
data class RingtoneSetting(
|
||||
/** The alarm's own override. null = inherit, `Ringtones.SILENT_URI` = silent. */
|
||||
val storedUri: String?,
|
||||
/** After `resolveSettings`. null means "let the device decide". */
|
||||
val effectiveUri: String?,
|
||||
val inherited: Boolean,
|
||||
val silent: Boolean,
|
||||
/** Resolved display title, or null when the device cannot name it. */
|
||||
val title: String?,
|
||||
/** [effectiveUri] cannot be opened for reading right now (M5 D19). */
|
||||
val unavailable: Boolean,
|
||||
/** A picked document's read grant could not be made persistent (M5 D20). */
|
||||
val accessMayLapse: Boolean,
|
||||
)
|
||||
|
||||
sealed interface AlarmEditorUiState {
|
||||
data object Loading : AlarmEditorUiState
|
||||
|
||||
/** The alarm is not there any more — deleted here or elsewhere. The screen pops. */
|
||||
data object Gone : AlarmEditorUiState
|
||||
|
||||
data class Editing(
|
||||
val alarmId: Long,
|
||||
val time: TimeOfDay,
|
||||
val label: String,
|
||||
val repeatDays: RepeatDays,
|
||||
val skipNextOccurrence: Boolean,
|
||||
/** False for a one-shot alarm: skipping it would mean disabling it (M5 D14). */
|
||||
val canSkipNext: Boolean,
|
||||
val ringtone: RingtoneSetting,
|
||||
val vibrate: OverrideValue<Boolean>,
|
||||
val snoozeMinutes: OverrideValue<Int>,
|
||||
val snoozeLimit: OverrideValue<Int>,
|
||||
val volumeRampSeconds: OverrideValue<Int>,
|
||||
val dismissChallenge: OverrideValue<DismissChallenge>,
|
||||
/** So a picker can label "App default (10 minutes)". */
|
||||
val defaults: ClockDefaults,
|
||||
) : AlarmEditorUiState
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
package de.jeanlucmakiola.clockula.ui.alarms
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
|
||||
import de.jeanlucmakiola.clockula.data.alarms.AlarmRepository
|
||||
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.domain.Alarm
|
||||
import de.jeanlucmakiola.clockula.domain.ClockDefaults
|
||||
import de.jeanlucmakiola.clockula.domain.DismissChallenge
|
||||
import de.jeanlucmakiola.clockula.domain.Ringtones
|
||||
import de.jeanlucmakiola.clockula.domain.TimeOfDay
|
||||
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 java.time.DayOfWeek
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The editor writes as the user goes: no Save, no dirty state, no discard
|
||||
* dialog (M5 D22). Every write is a transactional read-modify-write, so a
|
||||
* concurrent engine write — a one-shot alarm disabling itself as its cycle
|
||||
* closes — is not clobbered by a stale whole-row write (D25).
|
||||
*/
|
||||
@HiltViewModel
|
||||
class AlarmEditorViewModel @Inject internal constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val alarms: AlarmRepository,
|
||||
private val settings: SettingsPrefs,
|
||||
private val engine: AlarmEngine,
|
||||
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 alarmId: Long = savedStateHandle.get<Long>(AlarmRoutes.ARG_ALARM_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 (D20). */
|
||||
private val accessMayLapse = MutableStateFlow(false)
|
||||
|
||||
private val _state = MutableStateFlow<AlarmEditorUiState>(AlarmEditorUiState.Loading)
|
||||
|
||||
val state: StateFlow<AlarmEditorUiState> = _state.asStateFlow()
|
||||
|
||||
private val _ringtonePicker = MutableStateFlow<RingtonePickerUiState?>(null)
|
||||
|
||||
/** Null while the picker is closed. */
|
||||
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>()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
combine(
|
||||
alarms.alarm(alarmId),
|
||||
settings.defaults,
|
||||
labelWrites,
|
||||
accessMayLapse,
|
||||
) { alarm, defaults, typedLabel, mayLapse ->
|
||||
Inputs(alarm, defaults, typedLabel, mayLapse)
|
||||
}.collectLatest { inputs ->
|
||||
_state.value = render(inputs)
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
labelWrites.collect { text ->
|
||||
if (text != null) alarms.edit(alarmId) { it.copy(label = text) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Clamped, never throws. */
|
||||
fun onTimePicked(hour: Int, minute: Int) =
|
||||
edit(reschedule = true) { it.copy(time = TimeOfDay.clamped(hour, minute)) }
|
||||
|
||||
fun onDayToggled(day: DayOfWeek) = edit(reschedule = true) { alarm ->
|
||||
val days = if (day in alarm.repeatDays) alarm.repeatDays - day else alarm.repeatDays + day
|
||||
alarm.copy(
|
||||
repeatDays = days,
|
||||
// A mask that stops repeating disarms the skip with it. Left armed,
|
||||
// the engine answers a one-shot-with-skip by *disabling* the alarm —
|
||||
// and `canSkipNext` has just gone false, so the editor would have
|
||||
// hidden the only control that could explain it (D14).
|
||||
skipNextOccurrence = alarm.skipNextOccurrence && days.isRepeating,
|
||||
)
|
||||
}
|
||||
|
||||
/** Verbatim, conflated (M5 D24). */
|
||||
fun onLabelTyped(text: String) {
|
||||
// Untrimmed: `Alarm.label` is verbatim everywhere else, and trimming as
|
||||
// the user types would eat the space they just pressed.
|
||||
labelWrites.value = text
|
||||
}
|
||||
|
||||
fun onSkipNextChanged(skip: Boolean) {
|
||||
viewModelScope.launch {
|
||||
alarms.setSkipNextOccurrence(alarmId, skip)
|
||||
engine.reschedule()
|
||||
}
|
||||
}
|
||||
|
||||
/** null = inherit. */
|
||||
fun onVibrateChanged(value: Boolean?) = edit { it.copy(vibrate = value) }
|
||||
|
||||
fun onSnoozeMinutesChanged(value: Int?) = edit { it.copy(snoozeMinutes = value) }
|
||||
|
||||
fun onSnoozeLimitChanged(value: Int?) = edit { it.copy(snoozeLimit = value) }
|
||||
|
||||
fun onVolumeRampChanged(value: Int?) = edit { it.copy(volumeRampSeconds = value) }
|
||||
|
||||
fun onDismissChallengeChanged(value: DismissChallenge?) = edit { it.copy(dismissChallenge = value) }
|
||||
|
||||
fun onOpenRingtonePicker() {
|
||||
val editing = _state.value as? AlarmEditorUiState.Editing ?: return
|
||||
val stored = editing.ringtone.storedUri
|
||||
val appDefault = editing.defaults.alarmRingtoneUri
|
||||
_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, and the row
|
||||
// would then claim the device default instead (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()
|
||||
edit { it.copy(ringtoneUri = Ringtones.normalise(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 (D20).
|
||||
accessMayLapse.value = !ringtones.persistPickedDocument(picked)
|
||||
alarms.edit(alarmId) { it.copy(ringtoneUri = 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
|
||||
// alarm tones at once is not a preview of either (D21).
|
||||
if (previewing != null) stopPreview()
|
||||
previewing = sound
|
||||
viewModelScope.launch { preview.play(sound) }
|
||||
}
|
||||
|
||||
/** Dismisses a ring first (M5 D11), deletes, and the state becomes [AlarmEditorUiState.Gone]. */
|
||||
fun onDelete() {
|
||||
viewModelScope.launch {
|
||||
if (engine.ringSession()?.alarm?.id == alarmId) engine.dismiss(alarmId)
|
||||
alarms.delete(alarmId)
|
||||
engine.reschedule()
|
||||
_state.value = AlarmEditorUiState.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 { alarms.edit(alarmId) { it.copy(label = text) } }
|
||||
}
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
private fun stopPreview() {
|
||||
previewing = null
|
||||
preview.stop()
|
||||
}
|
||||
|
||||
/**
|
||||
* Every editor write. [reschedule] is on only for a change that can move a
|
||||
* fire time — so fiddling with sound settings costs no AlarmManager traffic
|
||||
* (M5 D12).
|
||||
*/
|
||||
private fun edit(reschedule: Boolean = false, transform: (Alarm) -> Alarm) {
|
||||
viewModelScope.launch {
|
||||
alarms.edit(alarmId, transform)
|
||||
if (reschedule) engine.reschedule()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun render(inputs: Inputs): AlarmEditorUiState {
|
||||
val alarm = inputs.alarm ?: return AlarmEditorUiState.Gone
|
||||
return AlarmEditorUiState.Editing(
|
||||
alarmId = alarm.id,
|
||||
time = alarm.time,
|
||||
label = inputs.typedLabel ?: alarm.label,
|
||||
repeatDays = alarm.repeatDays,
|
||||
skipNextOccurrence = alarm.skipNextOccurrence,
|
||||
// A one-shot's only occurrence cannot be skipped: the resolver
|
||||
// answers that by disabling the alarm (D14).
|
||||
canSkipNext = alarm.repeatDays.isRepeating,
|
||||
ringtone = ringtoneSetting(alarm, inputs.defaults, inputs.accessMayLapse),
|
||||
vibrate = AlarmOverrides.of(alarm.vibrate, inputs.defaults.vibrate),
|
||||
snoozeMinutes = AlarmOverrides.of(alarm.snoozeMinutes, inputs.defaults.snoozeMinutes),
|
||||
snoozeLimit = AlarmOverrides.of(alarm.snoozeLimit, inputs.defaults.snoozeLimit),
|
||||
volumeRampSeconds = AlarmOverrides.of(alarm.volumeRampSeconds, inputs.defaults.volumeRampSeconds),
|
||||
dismissChallenge = AlarmOverrides.of(alarm.dismissChallenge, inputs.defaults.dismissChallenge),
|
||||
defaults = inputs.defaults,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun ringtoneSetting(
|
||||
alarm: Alarm,
|
||||
defaults: ClockDefaults,
|
||||
accessMayLapse: Boolean,
|
||||
): RingtoneSetting {
|
||||
val effective = alarm.ringtoneUri ?: defaults.alarmRingtoneUri
|
||||
val silent = Ringtones.isSilent(effective)
|
||||
val meta = effective?.takeUnless { silent }?.let { metaOf(it) }
|
||||
return RingtoneSetting(
|
||||
storedUri = alarm.ringtoneUri,
|
||||
effectiveUri = effective,
|
||||
inherited = alarm.ringtoneUri == null,
|
||||
silent = silent,
|
||||
title = meta?.title,
|
||||
// Reported, never rewritten: "unreadable right now" and "gone
|
||||
// forever" are indistinguishable, and an unmounted card comes back
|
||||
// (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. The state is re-rendered
|
||||
* on **every keystroke** (D24), and awaiting two provider round-trips per
|
||||
* character made the label field lag the IME: `collectLatest` cancels the
|
||||
* previous render, so the field's text only caught up once the newest
|
||||
* keystroke's I/O had finished. Keyed on the URI, so a *change* of sound
|
||||
* still asks (D19) 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 four inputs the state is a pure function of, once the catalog has answered. */
|
||||
private data class Inputs(
|
||||
val alarm: Alarm?,
|
||||
val defaults: ClockDefaults,
|
||||
val typedLabel: String?,
|
||||
val accessMayLapse: Boolean,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package de.jeanlucmakiola.clockula.ui.alarms
|
||||
|
||||
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
|
||||
import de.jeanlucmakiola.clockula.domain.time.Ticker
|
||||
import de.jeanlucmakiola.clockula.domain.time.WallClock
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** The engine, the ticker and the wall clock, combined into the list's rows. */
|
||||
@Singleton
|
||||
internal class AlarmListSource @Inject constructor(
|
||||
private val engine: AlarmEngine,
|
||||
private val wall: WallClock,
|
||||
private val ticker: Ticker,
|
||||
) {
|
||||
/**
|
||||
* Distinct-until-changed. The cadence is the engine's `refresh` parameter,
|
||||
* not a countdown of our own: the screen takes the engine's resolution and
|
||||
* re-derives none of its time arithmetic (M5 D8).
|
||||
*/
|
||||
fun rows(): Flow<List<AlarmRowState>> = engine
|
||||
.upcoming(ticker.ticks(AlarmsDefaults.NextFireTick))
|
||||
.map { upcoming -> AlarmListRows.from(upcoming, wall.now()) }
|
||||
.distinctUntilChanged()
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package de.jeanlucmakiola.clockula.ui.alarms
|
||||
|
||||
/** The editor's route and the name its argument is declared under (M5 D3). */
|
||||
internal object AlarmRoutes {
|
||||
const val ARG_ALARM_ID: String = "alarmId"
|
||||
const val EDITOR_PATTERN: String = "alarms/edit/{alarmId}"
|
||||
|
||||
fun editor(alarmId: Long): String = "alarms/edit/$alarmId"
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package de.jeanlucmakiola.clockula.ui.alarms
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
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 de.jeanlucmakiola.clockula.R
|
||||
import de.jeanlucmakiola.clockula.domain.RepeatDays
|
||||
import de.jeanlucmakiola.clockula.domain.RepeatDaysSummary
|
||||
import de.jeanlucmakiola.clockula.domain.RepeatSummary
|
||||
import de.jeanlucmakiola.clockula.domain.format.NextFireLabel
|
||||
import de.jeanlucmakiola.clockula.ui.common.rememberAlarmTimeFormatter
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import java.time.DayOfWeek
|
||||
import java.time.format.TextStyle
|
||||
import java.time.temporal.WeekFields
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* One alarm, as the canonical two-line grouped row: the time (with the label
|
||||
* beside it), the repeat summary and the next-fire line, and the switch.
|
||||
*
|
||||
* A disabled alarm is `dimmed` — the kit's own answer for "present but
|
||||
* switched off" — and the *next* alarm is marked with colour rather than size:
|
||||
* Expressive is not big or bold (M5 D7, D15).
|
||||
*/
|
||||
@Composable
|
||||
internal fun AlarmRow(
|
||||
row: AlarmRowState,
|
||||
position: Position,
|
||||
onClick: () -> Unit,
|
||||
onToggle: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val formatTime = rememberAlarmTimeFormatter()
|
||||
val time = formatTime(row.time)
|
||||
val toggleDescription = stringResource(R.string.alarm_row_toggle, time)
|
||||
GroupedRow(
|
||||
title = rowTitle(time, row.label),
|
||||
summary = rowSummary(row),
|
||||
position = position,
|
||||
dimmed = !row.enabled,
|
||||
modifier = modifier,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = row.enabled,
|
||||
onCheckedChange = onToggle,
|
||||
// "Alarm at 07:00" — a bare switch announces neither which row
|
||||
// it belongs to nor what it does.
|
||||
modifier = Modifier.semantics { contentDescription = toggleDescription },
|
||||
)
|
||||
},
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rowTitle(time: String, label: String): AnnotatedString {
|
||||
val variant = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
return buildAnnotatedString {
|
||||
append(time)
|
||||
if (label.isNotBlank()) {
|
||||
append(" ")
|
||||
withStyle(SpanStyle(color = variant)) { append(label) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rowSummary(row: AlarmRowState): AnnotatedString {
|
||||
val primary = MaterialTheme.colorScheme.primary
|
||||
val repeat = repeatSummaryText(row.repeatDays)
|
||||
val next = nextFireClause(row)
|
||||
val skipping = stringResource(R.string.alarm_skipping_next)
|
||||
val separator = stringResource(R.string.alarm_summary_separator)
|
||||
return buildAnnotatedString {
|
||||
append(repeat)
|
||||
if (next != null) {
|
||||
append(separator)
|
||||
if (row.isNext) withStyle(SpanStyle(color = primary)) { append(next) } else append(next)
|
||||
}
|
||||
if (row.skippingNext) {
|
||||
append(separator)
|
||||
append(skipping)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Ringing and snoozed say so; anything else is the countdown, or nothing. */
|
||||
@Composable
|
||||
private fun nextFireClause(row: AlarmRowState): String? = when {
|
||||
row.ringing -> stringResource(R.string.alarm_next_ringing)
|
||||
row.snoozed -> nextFireText(row.nextFire)?.let { stringResource(R.string.alarm_next_snoozed, it) }
|
||||
else -> nextFireText(row.nextFire)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun nextFireText(label: NextFireLabel): String? = when (label) {
|
||||
NextFireLabel.NotScheduled -> null
|
||||
NextFireLabel.Imminent -> stringResource(R.string.alarm_next_imminent)
|
||||
is NextFireLabel.InMinutes ->
|
||||
pluralStringResource(R.plurals.alarm_next_in_minutes, label.minutes, label.minutes)
|
||||
// A whole hour or a whole day reads as one unit: "in 1h", not "in 1h 0m".
|
||||
is NextFireLabel.InHoursMinutes -> if (label.minutes == 0) {
|
||||
stringResource(R.string.alarm_next_in_hours, label.hours)
|
||||
} else {
|
||||
stringResource(R.string.alarm_next_in_hours_minutes, label.hours, label.minutes)
|
||||
}
|
||||
is NextFireLabel.InDaysHours -> if (label.hours == 0) {
|
||||
stringResource(R.string.alarm_next_in_days, label.days)
|
||||
} else {
|
||||
stringResource(R.string.alarm_next_in_days_hours, label.days, label.hours)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun repeatSummaryText(repeatDays: RepeatDays): String {
|
||||
val locale = rememberLocale()
|
||||
return when (val summary = RepeatDaysSummary.of(repeatDays, rememberWeekStart())) {
|
||||
RepeatSummary.Once -> stringResource(R.string.alarm_repeat_once)
|
||||
RepeatSummary.EveryDay -> stringResource(R.string.alarm_repeat_every_day)
|
||||
RepeatSummary.Weekdays -> stringResource(R.string.alarm_repeat_weekdays)
|
||||
RepeatSummary.Weekends -> stringResource(R.string.alarm_repeat_weekends)
|
||||
is RepeatSummary.SomeDays -> summary.days.joinToString(", ") {
|
||||
it.getDisplayName(TextStyle.SHORT, locale)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The locale's own first day of week, re-read with the configuration. */
|
||||
@Composable
|
||||
internal fun rememberWeekStart(): DayOfWeek {
|
||||
val locale = rememberLocale()
|
||||
return remember(locale) { WeekFields.of(locale).firstDayOfWeek }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberLocale(): Locale {
|
||||
// Keyed on the configuration, so a language change re-reads both the
|
||||
// locale's day names and where its week begins.
|
||||
val configuration = LocalConfiguration.current
|
||||
return remember(configuration) { Locale.getDefault() }
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package de.jeanlucmakiola.clockula.ui.alarms
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.RepeatDays
|
||||
import de.jeanlucmakiola.clockula.domain.TimeOfDay
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.FireSource
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.UpcomingAlarm
|
||||
import de.jeanlucmakiola.clockula.domain.format.NextFireFormat
|
||||
import de.jeanlucmakiola.clockula.domain.format.NextFireLabel
|
||||
import kotlin.time.Instant
|
||||
|
||||
data class AlarmRowState(
|
||||
val id: Long,
|
||||
val time: TimeOfDay,
|
||||
/** Verbatim; blank stays blank — the row supplies the fallback. */
|
||||
val label: String,
|
||||
val enabled: Boolean,
|
||||
/** Carried raw: the summary needs the locale, which belongs to the composable. */
|
||||
val repeatDays: RepeatDays,
|
||||
val nextFire: NextFireLabel,
|
||||
val snoozed: Boolean,
|
||||
val ringing: Boolean,
|
||||
val skippingNext: Boolean,
|
||||
/** The earliest non-null next fire. Never a ringing alarm. */
|
||||
val isNext: Boolean,
|
||||
)
|
||||
|
||||
sealed interface AlarmsUiState {
|
||||
/** Before the first repository emission. Not the empty state. */
|
||||
data object Loading : AlarmsUiState
|
||||
data class Loaded(val rows: List<AlarmRowState>) : AlarmsUiState
|
||||
}
|
||||
|
||||
internal object AlarmListRows {
|
||||
/** Pure. Orders by time of day then id; marks `isNext`; never throws. */
|
||||
fun from(upcoming: List<UpcomingAlarm>, now: Instant): List<AlarmRowState> {
|
||||
// The mark goes on the earliest *coming* alarm — the engine's own order,
|
||||
// which is not the list's (M5 D13): a row must not move because a
|
||||
// countdown crossed another row's.
|
||||
val next = upcoming
|
||||
.filter { it.nextFire != null && it.source != FireSource.RESUMED_RING }
|
||||
.minWithOrNull(compareBy({ it.nextFire }, { it.alarm.id }))
|
||||
|
||||
return upcoming
|
||||
.sortedWith(compareBy({ it.alarm.time.minutesOfDay }, { it.alarm.id }))
|
||||
.map { entry ->
|
||||
val alarm = entry.alarm
|
||||
val ringing = entry.source == FireSource.RESUMED_RING
|
||||
AlarmRowState(
|
||||
id = alarm.id,
|
||||
time = alarm.time,
|
||||
label = alarm.label,
|
||||
enabled = alarm.enabled,
|
||||
repeatDays = alarm.repeatDays,
|
||||
nextFire = NextFireFormat.labelFor(entry.nextFire, now),
|
||||
snoozed = entry.source == FireSource.SNOOZE,
|
||||
ringing = ringing,
|
||||
// A one-shot alarm answers a skip by disabling itself, so
|
||||
// only a repeating alarm can be "skipping next" (M5 D14).
|
||||
skippingNext = alarm.skipNextOccurrence && alarm.repeatDays.isRepeating,
|
||||
isNext = !ringing && next?.alarm?.id == alarm.id,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package de.jeanlucmakiola.clockula.ui.alarms
|
||||
|
||||
import android.text.format.DateFormat
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TimeInput
|
||||
import androidx.compose.material3.TimePicker
|
||||
import androidx.compose.material3.TimePickerDialog
|
||||
import androidx.compose.material3.TimePickerDialogDefaults
|
||||
import androidx.compose.material3.TimePickerDisplayMode
|
||||
import androidx.compose.material3.rememberTimePickerState
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import de.jeanlucmakiola.clockula.R
|
||||
import de.jeanlucmakiola.clockula.domain.TimeOfDay
|
||||
|
||||
/**
|
||||
* The canonical M3 time picker, hosted in its own dialog: a keypad to type a
|
||||
* time and the dial behind the mode toggle, both over the device's own
|
||||
* 12/24-hour setting so the control matches every other clock on the phone.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
internal fun AlarmTimePickerDialog(
|
||||
initial: TimeOfDay,
|
||||
onConfirm: (hour: Int, minute: Int) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val state = rememberTimePickerState(
|
||||
initialHour = initial.hour,
|
||||
initialMinute = initial.minute,
|
||||
is24Hour = DateFormat.is24HourFormat(context),
|
||||
)
|
||||
// A boolean rather than the value-class mode itself, so it survives process
|
||||
// death without a Saver of its own.
|
||||
var typing by rememberSaveable { mutableStateOf(true) }
|
||||
val displayMode = if (typing) TimePickerDisplayMode.Input else TimePickerDisplayMode.Picker
|
||||
|
||||
TimePickerDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
confirmButton = {
|
||||
TextButton(onClick = { onConfirm(state.hour, state.minute) }) {
|
||||
Text(stringResource(R.string.action_ok))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) }
|
||||
},
|
||||
title = { TimePickerDialogDefaults.Title(displayMode = displayMode) },
|
||||
modeToggleButton = {
|
||||
TimePickerDialogDefaults.DisplayModeToggle(
|
||||
onDisplayModeChange = { typing = !typing },
|
||||
displayMode = displayMode,
|
||||
)
|
||||
},
|
||||
) {
|
||||
if (typing) TimeInput(state = state) else TimePicker(state = state)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package de.jeanlucmakiola.clockula.ui.alarms
|
||||
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
internal object AlarmsDefaults {
|
||||
/** Half the readout's unit, so the line is never more than 30 s stale (M5 D9). */
|
||||
val NextFireTick: Duration = 30.seconds
|
||||
|
||||
/** The band the FAB occupies: its 56 dp plus the 16 dp margin. */
|
||||
val FabBand: Dp = 72.dp
|
||||
|
||||
val ListBottomPadding: Dp = 8.dp
|
||||
}
|
||||
@@ -1,17 +1,121 @@
|
||||
package de.jeanlucmakiola.clockula.ui.alarms
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
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.ui.shell.LocalLivePillInset
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.components.GroupedSurface
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
|
||||
/** A top-level tab: its title bar and its empty state. The alarm list is M5's. */
|
||||
/**
|
||||
* The Alarms tab: a live list of alarms, each saying when it will ring, and a
|
||||
* FAB that creates one and opens its editor.
|
||||
*
|
||||
* A `LazyColumn` inside `CollapsingScaffold(scrollable = false)`: an alarm list
|
||||
* is short in practice but unbounded in principle, and the lazy list gets the
|
||||
* top bar's collapse for free through the shared nested-scroll connection
|
||||
* (M5 D4).
|
||||
*/
|
||||
@Composable
|
||||
fun AlarmsScreen(modifier: Modifier = Modifier) {
|
||||
EmptyTabScreen(
|
||||
fun AlarmsScreen(
|
||||
onEditAlarm: (Long) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: AlarmsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val pillInset = LocalLivePillInset.current
|
||||
val addAlarm = stringResource(R.string.alarms_add)
|
||||
|
||||
LaunchedEffect(viewModel) {
|
||||
// The FAB creates the alarm and the editor edits a persisted row, so
|
||||
// the id to navigate to arrives from the ViewModel (M5 D2, D22).
|
||||
viewModel.openEditor.collectLatest(onEditAlarm)
|
||||
}
|
||||
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.tab_alarms),
|
||||
message = stringResource(R.string.alarms_empty),
|
||||
modifier = modifier,
|
||||
)
|
||||
scrollable = false,
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(
|
||||
onClick = viewModel::onAddAlarm,
|
||||
// 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 = addAlarm)
|
||||
}
|
||||
},
|
||||
) {
|
||||
val rows = (state as? AlarmsUiState.Loaded)?.rows
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(
|
||||
top = AlarmsDefaults.ListBottomPadding,
|
||||
bottom = AlarmsDefaults.ListBottomPadding + AlarmsDefaults.FabBand + pillInset,
|
||||
),
|
||||
) {
|
||||
if (rows != null && rows.isEmpty()) {
|
||||
item { AlarmsEmptyState() }
|
||||
}
|
||||
itemsIndexed(rows.orEmpty(), key = { _, row -> row.id }) { index, row ->
|
||||
AlarmRow(
|
||||
row = row,
|
||||
position = positionOf(index, rows.orEmpty().size),
|
||||
onClick = { onEditAlarm(row.id) },
|
||||
onToggle = { enabled -> viewModel.onToggleEnabled(row.id, enabled) },
|
||||
// An alarm whose time changed slides to its new place
|
||||
// instead of teleporting.
|
||||
modifier = animateItemMotion(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A real empty state, not a flash: the list has loaded and there is nothing in it. */
|
||||
@Composable
|
||||
private fun AlarmsEmptyState(modifier: Modifier = Modifier) {
|
||||
GroupedSurface(
|
||||
position = Position.Alone,
|
||||
modifier = modifier.padding(horizontal = GroupedListInset),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 24.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.alarms_empty),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.alarms_empty_hint),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package de.jeanlucmakiola.clockula.ui.alarms
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
|
||||
import de.jeanlucmakiola.clockula.data.alarms.AlarmRepository
|
||||
import de.jeanlucmakiola.clockula.domain.AlarmDefaults
|
||||
import de.jeanlucmakiola.clockula.domain.AlarmDraft
|
||||
import de.jeanlucmakiola.clockula.domain.time.WallClock
|
||||
import de.jeanlucmakiola.clockula.domain.time.ZoneProvider
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/** The alarm list: what it shows, the "+" that creates a row, and the row's switch. */
|
||||
@HiltViewModel
|
||||
class AlarmsViewModel @Inject internal constructor(
|
||||
private val alarms: AlarmRepository,
|
||||
private val engine: AlarmEngine,
|
||||
private val wall: WallClock,
|
||||
private val zones: ZoneProvider,
|
||||
private val source: AlarmListSource,
|
||||
) : ViewModel() {
|
||||
|
||||
val state: StateFlow<AlarmsUiState> = source.rows()
|
||||
.map<List<AlarmRowState>, AlarmsUiState>(AlarmsUiState::Loaded)
|
||||
// `WhileSubscribed`, not `Eagerly`: the upstream is a 30-second ticker
|
||||
// plus a full resolver pass over every alarm, and eager sharing kept it
|
||||
// waking 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.
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(SUBSCRIPTION_GRACE_MS), AlarmsUiState.Loading)
|
||||
|
||||
// A one-shot, and *conflated*: an id that has already been navigated to must
|
||||
// not be replayed on the next collection, and two ids must never stack up
|
||||
// into two navigations (M5 D22).
|
||||
private val newAlarms = Channel<Long>(Channel.CONFLATED)
|
||||
|
||||
/** One-shot: the id of a freshly created alarm, for the caller to navigate to. */
|
||||
val openEditor: Flow<Long> = newAlarms.receiveAsFlow().onEach { creating = false }
|
||||
|
||||
/**
|
||||
* True from the tap until the id has been handed over. A double-tapped FAB
|
||||
* would otherwise create **two** alarms and push two editors onto the stack,
|
||||
* leaving the user on the second one with the first behind back.
|
||||
*/
|
||||
private var creating: Boolean = false
|
||||
|
||||
/** Creates an enabled one-shot alarm at the next whole hour, then emits its id. */
|
||||
fun onAddAlarm() {
|
||||
if (creating) return
|
||||
creating = true
|
||||
viewModelScope.launch {
|
||||
val time = AlarmDefaults.nextWholeHour(wall.now(), zones.current())
|
||||
val id = alarms.create(AlarmDraft(time = time))
|
||||
// Created enabled, so it schedules at once — which is what tapping
|
||||
// "+" asked for (M5 D22).
|
||||
engine.reschedule()
|
||||
newAlarms.send(id)
|
||||
}
|
||||
}
|
||||
|
||||
/** Dismisses a ring first when switching off (M5 D11), then re-resolves. */
|
||||
fun onToggleEnabled(alarmId: Long, enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
if (!enabled) stopRingIfRinging(alarmId)
|
||||
alarms.setEnabled(alarmId, enabled)
|
||||
engine.reschedule()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The ring service is a single global slot the resolver cannot reach, and
|
||||
* its auto-silence backstop disarms itself once `ringingSince` is null — so
|
||||
* switching off a *ringing* alarm has to go through the engine first, or it
|
||||
* sounds until the wake lock expires. Conditional, because dismissing a
|
||||
* merely snoozed alarm would drop a snooze it should keep (M5 D11).
|
||||
*/
|
||||
private suspend fun stopRingIfRinging(alarmId: Long) {
|
||||
if (engine.ringSession()?.alarm?.id == alarmId) engine.dismiss(alarmId)
|
||||
}
|
||||
}
|
||||
|
||||
/** The window a rotation has to re-subscribe in before the ticker is dropped. */
|
||||
private const val SUBSCRIPTION_GRACE_MS = 5_000L
|
||||
@@ -0,0 +1,178 @@
|
||||
package de.jeanlucmakiola.clockula.ui.alarms
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import de.jeanlucmakiola.clockula.R
|
||||
import de.jeanlucmakiola.clockula.domain.DismissChallenge
|
||||
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||
|
||||
/**
|
||||
* The five per-alarm overrides, each edited through a picker whose first option
|
||||
* is "App default".
|
||||
*
|
||||
* A stored value is *ternary* — absent, or a choice that happens to be `false`
|
||||
* or `0` — and a `Switch` cannot say "I have not chosen": toggling one would
|
||||
* silently turn "follows your default" into "explicitly on", with no way back
|
||||
* (M5 D16). Every option offered is inside `ClockPrefs`' own clamps, so
|
||||
* nothing chosen here can be clamped away on the next read.
|
||||
*/
|
||||
internal object OverrideOptions {
|
||||
val Vibrate: List<Boolean?> = listOf(null, true, false)
|
||||
|
||||
val VolumeRampSeconds: List<Int?> = listOf(null, 0, 5, 15, 30, 60)
|
||||
|
||||
val SnoozeMinutes: List<Int?> = listOf(null, 1, 5, 9, 10, 15, 20, 30)
|
||||
|
||||
val SnoozeLimit: List<Int?> = listOf(null, 0, 1, 2, 3, 5, 10)
|
||||
|
||||
val DismissChallenges: List<DismissChallenge?> =
|
||||
listOf(null, DismissChallenge.NONE, DismissChallenge.MATH, DismissChallenge.HOLD)
|
||||
}
|
||||
|
||||
/** "App default (10 minutes)" while inherited, so the inheritance is visible in the row. */
|
||||
@Composable
|
||||
internal fun overrideSummary(inherited: Boolean, value: String): String =
|
||||
if (inherited) stringResource(R.string.override_app_default_value, value) else value
|
||||
|
||||
@Composable
|
||||
internal fun vibrateText(on: Boolean): String =
|
||||
stringResource(if (on) R.string.value_on else R.string.value_off)
|
||||
|
||||
@Composable
|
||||
internal fun volumeRampText(seconds: Int): String = if (seconds == 0) {
|
||||
stringResource(R.string.volume_ramp_none)
|
||||
} else {
|
||||
pluralStringResource(R.plurals.volume_ramp_seconds, seconds, seconds)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun snoozeMinutesText(minutes: Int): String =
|
||||
pluralStringResource(R.plurals.snooze_minutes, minutes, minutes)
|
||||
|
||||
/** Zero is "No snoozing", never "unlimited": "0 means infinite" is a trap. */
|
||||
@Composable
|
||||
internal fun snoozeLimitText(limit: Int): String = if (limit == 0) {
|
||||
stringResource(R.string.snooze_limit_none)
|
||||
} else {
|
||||
pluralStringResource(R.plurals.snooze_limit_times, limit, limit)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun challengeText(challenge: DismissChallenge): String = stringResource(
|
||||
when (challenge) {
|
||||
DismissChallenge.NONE -> R.string.challenge_none
|
||||
DismissChallenge.MATH -> R.string.challenge_math
|
||||
DismissChallenge.HOLD -> R.string.challenge_hold
|
||||
},
|
||||
)
|
||||
|
||||
@Composable
|
||||
internal fun VibratePicker(
|
||||
stored: Boolean?,
|
||||
default: Boolean,
|
||||
onSelect: (Boolean?) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) = OverridePicker(
|
||||
title = stringResource(R.string.editor_vibrate),
|
||||
options = OverrideOptions.Vibrate,
|
||||
stored = stored,
|
||||
default = default,
|
||||
text = { vibrateText(it) },
|
||||
onSelect = onSelect,
|
||||
onDismiss = onDismiss,
|
||||
)
|
||||
|
||||
@Composable
|
||||
internal fun VolumeRampPicker(
|
||||
stored: Int?,
|
||||
default: Int,
|
||||
onSelect: (Int?) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) = OverridePicker(
|
||||
title = stringResource(R.string.editor_volume_ramp),
|
||||
options = OverrideOptions.VolumeRampSeconds,
|
||||
stored = stored,
|
||||
default = default,
|
||||
text = { volumeRampText(it) },
|
||||
onSelect = onSelect,
|
||||
onDismiss = onDismiss,
|
||||
)
|
||||
|
||||
@Composable
|
||||
internal fun SnoozeLengthPicker(
|
||||
stored: Int?,
|
||||
default: Int,
|
||||
onSelect: (Int?) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) = OverridePicker(
|
||||
title = stringResource(R.string.editor_snooze_length),
|
||||
options = OverrideOptions.SnoozeMinutes,
|
||||
stored = stored,
|
||||
default = default,
|
||||
text = { snoozeMinutesText(it) },
|
||||
onSelect = onSelect,
|
||||
onDismiss = onDismiss,
|
||||
)
|
||||
|
||||
@Composable
|
||||
internal fun SnoozeLimitPicker(
|
||||
stored: Int?,
|
||||
default: Int,
|
||||
onSelect: (Int?) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) = OverridePicker(
|
||||
title = stringResource(R.string.editor_snooze_limit),
|
||||
options = OverrideOptions.SnoozeLimit,
|
||||
stored = stored,
|
||||
default = default,
|
||||
text = { snoozeLimitText(it) },
|
||||
onSelect = onSelect,
|
||||
onDismiss = onDismiss,
|
||||
)
|
||||
|
||||
@Composable
|
||||
internal fun DismissChallengePicker(
|
||||
stored: DismissChallenge?,
|
||||
default: DismissChallenge,
|
||||
onSelect: (DismissChallenge?) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) = OverridePicker(
|
||||
title = stringResource(R.string.editor_dismiss_challenge),
|
||||
options = OverrideOptions.DismissChallenges,
|
||||
stored = stored,
|
||||
default = default,
|
||||
text = { challengeText(it) },
|
||||
onSelect = onSelect,
|
||||
onDismiss = onDismiss,
|
||||
)
|
||||
|
||||
/**
|
||||
* One shape for all five: the "App default" rung names what the default
|
||||
* currently is, so choosing to inherit is not choosing blind.
|
||||
*/
|
||||
@Composable
|
||||
private fun <T : Any> OverridePicker(
|
||||
title: String,
|
||||
options: List<T?>,
|
||||
stored: T?,
|
||||
default: T,
|
||||
text: @Composable (T) -> String,
|
||||
onSelect: (T?) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
OptionPicker(
|
||||
title = title,
|
||||
options = options,
|
||||
selected = stored,
|
||||
label = { option ->
|
||||
if (option == null) {
|
||||
stringResource(R.string.override_app_default_value, text(default))
|
||||
} else {
|
||||
text(option)
|
||||
}
|
||||
},
|
||||
onSelect = onSelect,
|
||||
onDismiss = onDismiss,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package de.jeanlucmakiola.clockula.ui.alarms
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.ToggleButton
|
||||
import androidx.compose.material3.ToggleButtonDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.clockula.domain.RepeatDays
|
||||
import de.jeanlucmakiola.clockula.domain.RepeatDaysOrder
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.components.GroupedSurface
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import java.time.DayOfWeek
|
||||
import java.time.format.TextStyle
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Seven equal toggle buttons in one row — the Expressive round↔squircle morph
|
||||
* on check is the shape play a genuinely clock-specific control earns (M5 D28).
|
||||
*
|
||||
* The columns start on the locale's own first day of week, and each button
|
||||
* carries the day's full name as its content description: "M T W T F S S" is
|
||||
* not something a screen reader can disambiguate.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
internal fun RepeatDaySelector(
|
||||
repeatDays: RepeatDays,
|
||||
onDayToggled: (DayOfWeek) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val configuration = LocalConfiguration.current
|
||||
val locale = remember(configuration) { Locale.getDefault() }
|
||||
val days = RepeatDaysOrder.startingAt(rememberWeekStart())
|
||||
|
||||
GroupedSurface(
|
||||
position = Position.Alone,
|
||||
modifier = modifier.padding(horizontal = GroupedListInset),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp, vertical = 12.dp)
|
||||
.heightIn(min = 48.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
days.forEach { day ->
|
||||
val full = day.getDisplayName(TextStyle.FULL, locale)
|
||||
ToggleButton(
|
||||
checked = day in repeatDays,
|
||||
onCheckedChange = { onDayToggled(day) },
|
||||
shapes = ToggleButtonDefaults.shapes(),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.semantics { contentDescription = full },
|
||||
) {
|
||||
Text(day.getDisplayName(TextStyle.NARROW, locale))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package de.jeanlucmakiola.clockula.ui.alarms
|
||||
|
||||
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.LazyListScope
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.PlayArrow
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
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.Ringtones
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
|
||||
/**
|
||||
* The alarm sound picker: App default, Silent, the user's own file when the
|
||||
* device does not list it, and the device's own alarm tones — over a
|
||||
* `LazyColumn`, because a device's tone list can be hundreds of rows (M5 D17).
|
||||
*
|
||||
* "Choose from files…" is an action row rather than an option, so the option
|
||||
* list stays a list of selectable states. Each unselected row can be
|
||||
* auditioned through the play button; taking a row stops the preview and
|
||||
* closes the picker (D21).
|
||||
*
|
||||
* The SAF launcher is **not** registered here: this screen only exists while
|
||||
* the ViewModel says the picker is open, and that flag does not survive process
|
||||
* death — a result restored into a fresh process would have nowhere to land.
|
||||
* [onPickFile] comes from the host, which registers it unconditionally (D20).
|
||||
*/
|
||||
@Composable
|
||||
internal fun RingtonePickerScreen(
|
||||
state: RingtonePickerUiState,
|
||||
defaultUri: String?,
|
||||
onChoose: (String?) -> Unit,
|
||||
onPreview: (String?) -> Unit,
|
||||
onPickFile: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
FullScreenPicker(
|
||||
title = stringResource(R.string.ringtone_picker_title),
|
||||
onDismiss = onDismiss,
|
||||
scrollable = false,
|
||||
) {
|
||||
val rows = state.options.size + 1
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(vertical = 8.dp),
|
||||
) {
|
||||
if (state.deviceHasNoSounds) {
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(R.string.ringtone_none_available),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(
|
||||
horizontal = GroupedListInset,
|
||||
vertical = 8.dp,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (state.loading) {
|
||||
item {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.padding(GroupedListInset),
|
||||
)
|
||||
}
|
||||
}
|
||||
ringtoneOptions(state, rows, onChoose, onPreview, defaultUri)
|
||||
item {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.ringtone_pick_file),
|
||||
position = positionOf(rows - 1, rows),
|
||||
onClick = onPickFile,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun LazyListScope.ringtoneOptions(
|
||||
state: RingtonePickerUiState,
|
||||
rows: Int,
|
||||
onChoose: (String?) -> Unit,
|
||||
onPreview: (String?) -> Unit,
|
||||
defaultUri: String?,
|
||||
) {
|
||||
state.options.forEachIndexed { index, option ->
|
||||
item(key = optionKey(option)) {
|
||||
RingtoneOptionRow(
|
||||
option = option,
|
||||
position = positionOf(index, rows),
|
||||
selected = RingtonePicker.isSelected(option, state.selectedUri),
|
||||
defaultUri = defaultUri,
|
||||
defaultTitle = state.defaultTitle,
|
||||
onChoose = onChoose,
|
||||
onPreview = onPreview,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun optionKey(option: RingtoneChoice): String = when (option) {
|
||||
RingtoneChoice.AppDefault -> "app-default"
|
||||
RingtoneChoice.Silent -> "silent"
|
||||
is RingtoneChoice.Sound -> option.uri
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RingtoneOptionRow(
|
||||
option: RingtoneChoice,
|
||||
position: Position,
|
||||
selected: Boolean,
|
||||
defaultUri: String?,
|
||||
defaultTitle: String?,
|
||||
onChoose: (String?) -> Unit,
|
||||
onPreview: (String?) -> Unit,
|
||||
) {
|
||||
val unknown = stringResource(R.string.ringtone_unknown)
|
||||
val deviceDefault = stringResource(R.string.ringtone_device_default)
|
||||
val silentTitle = stringResource(R.string.ringtone_silent)
|
||||
val title = when (option) {
|
||||
RingtoneChoice.AppDefault -> stringResource(R.string.override_app_default)
|
||||
RingtoneChoice.Silent -> stringResource(R.string.ringtone_silent)
|
||||
is RingtoneChoice.Sound -> option.title.ifBlank { unknown }
|
||||
}
|
||||
val summary = when (option) {
|
||||
// What "App default" currently resolves to, so inheriting is not blind.
|
||||
// The title is the *resolved* one, not a scan of the device's tone list:
|
||||
// a SAF-picked default is absent from that list, and the row would then
|
||||
// claim the device default instead (M5 D16).
|
||||
RingtoneChoice.AppDefault -> when {
|
||||
defaultUri == null -> deviceDefault
|
||||
Ringtones.isSilent(defaultUri) -> silentTitle
|
||||
else -> defaultTitle ?: unknown
|
||||
}
|
||||
RingtoneChoice.Silent -> stringResource(R.string.ringtone_silent_summary)
|
||||
is RingtoneChoice.Sound -> null
|
||||
}
|
||||
val previewUri = when (option) {
|
||||
RingtoneChoice.AppDefault -> defaultUri
|
||||
RingtoneChoice.Silent -> null
|
||||
is RingtoneChoice.Sound -> option.uri
|
||||
}
|
||||
val chosenUri = when (option) {
|
||||
RingtoneChoice.AppDefault -> null
|
||||
RingtoneChoice.Silent -> Ringtones.SILENT_URI
|
||||
is RingtoneChoice.Sound -> option.uri
|
||||
}
|
||||
|
||||
GroupedRow(
|
||||
title = title,
|
||||
summary = summary,
|
||||
position = position,
|
||||
selected = selected,
|
||||
trailing = when {
|
||||
selected -> {
|
||||
{ SelectedCheck() }
|
||||
}
|
||||
previewUri != null -> {
|
||||
{
|
||||
IconButton(onClick = { onPreview(previewUri) }) {
|
||||
Icon(Icons.Rounded.PlayArrow, contentDescription = title)
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> null
|
||||
},
|
||||
onClick = { onChoose(chosenUri) },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package de.jeanlucmakiola.clockula.ui.alarms
|
||||
|
||||
import de.jeanlucmakiola.clockula.domain.RingtoneOption
|
||||
import de.jeanlucmakiola.clockula.domain.Ringtones
|
||||
|
||||
sealed interface RingtoneChoice {
|
||||
data object AppDefault : RingtoneChoice
|
||||
data object Silent : RingtoneChoice
|
||||
data class Sound(val uri: String, val title: String) : RingtoneChoice
|
||||
}
|
||||
|
||||
data class RingtonePickerUiState(
|
||||
val loading: Boolean,
|
||||
val options: List<RingtoneChoice>,
|
||||
/** The alarm's stored override: null = App default, sentinel = Silent. */
|
||||
val selectedUri: String?,
|
||||
/** The device reported no alarm sounds at all (M5 D19). */
|
||||
val deviceHasNoSounds: Boolean,
|
||||
/**
|
||||
* The app default's own resolved title, or null when nothing named it — so
|
||||
* the "App default" row can say what inheriting means even when the default
|
||||
* is a SAF-picked file the device's tone list does not contain (M5 D16).
|
||||
*/
|
||||
val defaultTitle: String?,
|
||||
)
|
||||
|
||||
internal object RingtonePicker {
|
||||
/** Pure. App default, Silent, the stored sound when absent from [deviceTones], then [deviceTones]. */
|
||||
fun optionsFor(
|
||||
deviceTones: List<RingtoneOption>,
|
||||
storedUri: String?,
|
||||
storedTitle: String?,
|
||||
): List<RingtoneChoice> {
|
||||
val stored = Ringtones.normalise(storedUri)
|
||||
// The user's own sound sits directly under Silent when the device does
|
||||
// not list it — a SAF-picked file would otherwise be invisible, and so
|
||||
// uncheckable, in the picker that chose it (M5 D17).
|
||||
val own = stored
|
||||
?.takeUnless { Ringtones.isSilent(it) }
|
||||
?.takeUnless { uri -> deviceTones.any { it.uri == uri } }
|
||||
?.let { RingtoneChoice.Sound(it, storedTitle.orEmpty()) }
|
||||
|
||||
val sounds = (listOfNotNull(own) + deviceTones.map { RingtoneChoice.Sound(it.uri, it.title) })
|
||||
.distinctBy { it.uri }
|
||||
return listOf(RingtoneChoice.AppDefault, RingtoneChoice.Silent) + sounds
|
||||
}
|
||||
|
||||
fun isSelected(option: RingtoneChoice, storedUri: String?): Boolean = when (option) {
|
||||
RingtoneChoice.AppDefault -> storedUri == null
|
||||
RingtoneChoice.Silent -> Ringtones.isSilent(storedUri)
|
||||
is RingtoneChoice.Sound -> option.uri == storedUri
|
||||
}
|
||||
}
|
||||
@@ -22,10 +22,14 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.navArgument
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
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.TimersScreen
|
||||
@@ -100,7 +104,20 @@ fun ClockulaShell(
|
||||
popEnterTransition = { fade.targetContentEnter },
|
||||
popExitTransition = { fade.initialContentExit },
|
||||
) {
|
||||
composable(ClockulaDestination.ALARMS.route) { AlarmsScreen() }
|
||||
composable(ClockulaDestination.ALARMS.route) {
|
||||
AlarmsScreen(onEditAlarm = { navController.navigate(AlarmRoutes.editor(it)) })
|
||||
}
|
||||
// A sibling route in the same flat host, not a nested one:
|
||||
// it is no tab, so the Alarms tab stays selected and the
|
||||
// shell stands aside on back (M5 D1).
|
||||
composable(
|
||||
route = AlarmRoutes.EDITOR_PATTERN,
|
||||
arguments = listOf(
|
||||
navArgument(AlarmRoutes.ARG_ALARM_ID) { type = NavType.LongType },
|
||||
),
|
||||
) {
|
||||
AlarmEditorScreen(onBack = { navController.popBackStack() })
|
||||
}
|
||||
composable(ClockulaDestination.TIMERS.route) { TimersScreen() }
|
||||
composable(ClockulaDestination.STOPWATCH.route) { StopwatchScreen() }
|
||||
composable(ClockulaDestination.WORLD_CLOCK.route) { WorldClockScreen() }
|
||||
|
||||
@@ -49,4 +49,81 @@
|
||||
<string name="ring_challenge_hold">Hold to dismiss</string>
|
||||
<string name="ring_challenge_escape">Dismiss without solving</string>
|
||||
<string name="ring_snooze_unavailable">No snoozes left</string>
|
||||
|
||||
<!-- M5: the alarms list -->
|
||||
<string name="alarms_add">Add alarm</string>
|
||||
<string name="alarms_empty_hint">Tap + to set your first alarm</string>
|
||||
<string name="alarm_row_toggle">Alarm at %1$s</string>
|
||||
<string name="alarm_next_imminent">in less than a minute</string>
|
||||
<plurals name="alarm_next_in_minutes">
|
||||
<item quantity="one">in %1$d min</item>
|
||||
<item quantity="other">in %1$d min</item>
|
||||
</plurals>
|
||||
<string name="alarm_next_in_hours">in %1$dh</string>
|
||||
<string name="alarm_next_in_hours_minutes">in %1$dh %2$dm</string>
|
||||
<string name="alarm_next_in_days">in %1$dd</string>
|
||||
<string name="alarm_next_in_days_hours">in %1$dd %2$dh</string>
|
||||
<string name="alarm_next_ringing">Ringing now</string>
|
||||
<string name="alarm_next_snoozed">Snoozed \u00b7 %1$s</string>
|
||||
<string name="alarm_skipping_next">Skipping next</string>
|
||||
<string name="alarm_repeat_once">Once</string>
|
||||
<string name="alarm_repeat_every_day">Every day</string>
|
||||
<string name="alarm_repeat_weekdays">Weekdays</string>
|
||||
<string name="alarm_repeat_weekends">Weekends</string>
|
||||
<string name="alarm_summary_separator" translatable="false">" \u00b7 "</string>
|
||||
|
||||
<!-- M5: the alarm editor -->
|
||||
<string name="editor_title">Alarm</string>
|
||||
<string name="editor_repeat">Repeat</string>
|
||||
<string name="editor_skip_next">Skip next alarm</string>
|
||||
<string name="editor_skip_next_summary">Ring the one after this</string>
|
||||
<string name="editor_label_placeholder">Alarm name</string>
|
||||
<string name="editor_section_sound">Sound and vibration</string>
|
||||
<string name="editor_section_snooze">Snooze and dismiss</string>
|
||||
<string name="editor_ringtone">Sound</string>
|
||||
<string name="editor_vibrate">Vibrate</string>
|
||||
<string name="editor_volume_ramp">Gradual volume</string>
|
||||
<string name="editor_snooze_length">Snooze length</string>
|
||||
<string name="editor_snooze_limit">Snooze limit</string>
|
||||
<string name="editor_dismiss_challenge">To dismiss</string>
|
||||
<string name="editor_delete">Delete alarm</string>
|
||||
<string name="editor_delete_title">Delete this alarm?</string>
|
||||
<string name="editor_delete_message">The %1$s alarm will be removed.</string>
|
||||
<string name="action_delete">Delete</string>
|
||||
<string name="action_cancel">Cancel</string>
|
||||
<string name="action_ok">OK</string>
|
||||
|
||||
<!-- M5: the per-alarm overrides -->
|
||||
<string name="override_app_default">App default</string>
|
||||
<string name="override_app_default_value">App default (%1$s)</string>
|
||||
<string name="value_on">On</string>
|
||||
<string name="value_off">Off</string>
|
||||
<string name="volume_ramp_none">No gradual volume</string>
|
||||
<plurals name="volume_ramp_seconds">
|
||||
<item quantity="one">%1$d second</item>
|
||||
<item quantity="other">%1$d seconds</item>
|
||||
</plurals>
|
||||
<plurals name="snooze_minutes">
|
||||
<item quantity="one">%1$d minute</item>
|
||||
<item quantity="other">%1$d minutes</item>
|
||||
</plurals>
|
||||
<string name="snooze_limit_none">No snoozing</string>
|
||||
<plurals name="snooze_limit_times">
|
||||
<item quantity="one">%1$d time</item>
|
||||
<item quantity="other">%1$d times</item>
|
||||
</plurals>
|
||||
<string name="challenge_none">Just tap Dismiss</string>
|
||||
<string name="challenge_math">Solve a sum</string>
|
||||
<string name="challenge_hold">Press and hold</string>
|
||||
|
||||
<!-- M5: the ringtone picker -->
|
||||
<string name="ringtone_picker_title">Alarm sound</string>
|
||||
<string name="ringtone_silent">Silent</string>
|
||||
<string name="ringtone_silent_summary">Vibration only</string>
|
||||
<string name="ringtone_device_default">Device default alarm sound</string>
|
||||
<string name="ringtone_pick_file">Choose from files\u2026</string>
|
||||
<string name="ringtone_none_available">This device has no alarm sounds. Clockula will use the system default.</string>
|
||||
<string name="ringtone_unavailable">Unavailable \u2014 the default alarm sound will be used</string>
|
||||
<string name="ringtone_unknown">Unknown sound</string>
|
||||
<string name="ringtone_access_may_lapse">Clockula may lose access to this file later</string>
|
||||
</resources>
|
||||
|
||||
@@ -131,6 +131,44 @@ class ArchitectureRulesTest {
|
||||
file.readLines().any { it.startsWith("import android.") || it.startsWith("import androidx.") }
|
||||
}.map { it.invariantSeparatorsPath }
|
||||
|
||||
assertThat(offenders).isEmpty()
|
||||
}
|
||||
/**
|
||||
* M5 D27: the decision surface — every `*ViewModel`, `*UiState` and
|
||||
* `*Source` — is JVM-constructible by construction. That is what earns the
|
||||
* no-Robolectric posture, so it is a build rule rather than a habit.
|
||||
*/
|
||||
private val decisionSurface: List<File> = kotlinSources.filter {
|
||||
it.name.endsWith("ViewModel.kt") ||
|
||||
it.name.endsWith("UiState.kt") ||
|
||||
it.name.endsWith("Source.kt")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the decision surface cannot acquire a Composable`() {
|
||||
val offenders = decisionSurface.filter { file ->
|
||||
file.readLines().any { it.startsWith("import androidx.compose.") }
|
||||
}.map { it.invariantSeparatorsPath }
|
||||
|
||||
assertThat(offenders).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the decision surface names no Android type`() {
|
||||
val offenders = decisionSurface.filter { file ->
|
||||
file.readLines().any { it.startsWith("import android.") }
|
||||
}.map { it.invariantSeparatorsPath }
|
||||
|
||||
assertThat(offenders).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the screen re-derives none of the engine's time arithmetic`() {
|
||||
val ui = kotlinSources
|
||||
.filter { it.invariantSeparatorsPath.contains("de/jeanlucmakiola/clockula/ui/") }
|
||||
|
||||
val offenders = ui.containing("AlarmResolver", "AlarmOccurrences")
|
||||
|
||||
assertThat(offenders).isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,705 @@
|
||||
package de.jeanlucmakiola.clockula.ui.alarms
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.clockula.data.alarms.AlarmMapper
|
||||
import de.jeanlucmakiola.clockula.domain.DismissChallenge
|
||||
import de.jeanlucmakiola.clockula.domain.RepeatDays
|
||||
import de.jeanlucmakiola.clockula.domain.Ringtones
|
||||
import de.jeanlucmakiola.clockula.domain.TimeOfDay
|
||||
import de.jeanlucmakiola.clockula.testing.AlarmEngineHarness
|
||||
import de.jeanlucmakiola.clockula.testing.BERLIN
|
||||
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.RingEvent
|
||||
import de.jeanlucmakiola.clockula.testing.alarmAt
|
||||
import de.jeanlucmakiola.clockula.testing.alarmEngineHarness
|
||||
import de.jeanlucmakiola.clockula.testing.instantAt
|
||||
import de.jeanlucmakiola.clockula.testing.offsetInstant
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
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 java.time.DayOfWeek
|
||||
|
||||
/**
|
||||
* The editor over the **real** alarm engine: every control writes at once
|
||||
* (M5 D22), a write is a transactional read-modify-write so a concurrent engine
|
||||
* write is not clobbered (D25), and a *ringing* alarm is silenced before it is
|
||||
* deleted (D11).
|
||||
*/
|
||||
class AlarmEditorViewModelTest {
|
||||
|
||||
@JvmField
|
||||
@RegisterExtension
|
||||
val main = MainDispatcherExtension()
|
||||
|
||||
private val catalog = FakeRingtoneCatalog()
|
||||
private val previewer = FakeRingtonePreviewer()
|
||||
|
||||
private val fiveToday = instantAt(BERLIN, "2026-06-10T05:00")
|
||||
private val sevenToday = offsetInstant("2026-06-10T07:00+02:00")
|
||||
private val sevenTomorrow = offsetInstant("2026-06-11T07:00+02:00")
|
||||
|
||||
private fun TestScope.editor(harness: AlarmEngineHarness, alarmId: Long = 1L) =
|
||||
AlarmEditorViewModel(
|
||||
savedStateHandle = SavedStateHandle(mapOf(AlarmRoutes.ARG_ALARM_ID to alarmId)),
|
||||
alarms = harness.alarms,
|
||||
settings = harness.settings,
|
||||
engine = harness.engine,
|
||||
ringtones = catalog,
|
||||
preview = previewer,
|
||||
appScope = this,
|
||||
)
|
||||
|
||||
private fun AlarmEditorViewModel.editing(): AlarmEditorUiState.Editing =
|
||||
state.value as AlarmEditorUiState.Editing
|
||||
|
||||
private suspend fun AlarmEngineHarness.givenAlarmWith(
|
||||
id: Long = 1L,
|
||||
hour: Int = 7,
|
||||
minute: Int = 0,
|
||||
repeatDays: RepeatDays = RepeatDays.NONE,
|
||||
label: String = "Gym",
|
||||
ringtoneUri: String? = null,
|
||||
vibrate: Boolean? = null,
|
||||
snoozeMinutes: Int? = null,
|
||||
) {
|
||||
val alarm = alarmAt(id = id, hour = hour, minute = minute, repeatDays = repeatDays, label = label)
|
||||
.copy(ringtoneUri = ringtoneUri, vibrate = vibrate, snoozeMinutes = snoozeMinutes)
|
||||
alarmDao.insert(AlarmMapper.toEntity(alarm))
|
||||
}
|
||||
|
||||
// --- loading ---
|
||||
|
||||
@Test
|
||||
fun `an id no alarm has is already gone`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
|
||||
val viewModel = editor(harness, alarmId = 404L)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(viewModel.state.value).isEqualTo(AlarmEditorUiState.Gone)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stored alarm arrives with its own fields`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith(hour = 7, minute = 5, label = "Gym", repeatDays = RepeatDays.WEEKDAYS)
|
||||
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
val editing = viewModel.editing()
|
||||
assertThat(listOf(editing.time, editing.label, editing.repeatDays, editing.skipNextOccurrence))
|
||||
.containsExactly(TimeOfDay(7, 5), "Gym", RepeatDays.WEEKDAYS, false)
|
||||
.inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an alarm with no overrides inherits every one of them`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith()
|
||||
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
val editing = viewModel.editing()
|
||||
assertThat(
|
||||
listOf(
|
||||
editing.vibrate,
|
||||
editing.snoozeMinutes,
|
||||
editing.snoozeLimit,
|
||||
editing.volumeRampSeconds,
|
||||
editing.dismissChallenge,
|
||||
),
|
||||
).containsExactly(
|
||||
OverrideValue(true, inherited = true),
|
||||
OverrideValue(10, inherited = true),
|
||||
OverrideValue(3, inherited = true),
|
||||
OverrideValue(15, inherited = true),
|
||||
OverrideValue(DismissChallenge.NONE, inherited = true),
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one stored override leaves the others inherited`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith(snoozeMinutes = 7)
|
||||
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
val editing = viewModel.editing()
|
||||
assertThat(editing.snoozeMinutes to editing.snoozeLimit)
|
||||
.isEqualTo(OverrideValue(7, inherited = false) to OverrideValue(3, inherited = true))
|
||||
}
|
||||
|
||||
// --- time and repeat ---
|
||||
|
||||
@Test
|
||||
fun `picking a time stores it and moves the registration`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith(hour = 7)
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onTimePicked(6, 45)
|
||||
advanceUntilIdle()
|
||||
|
||||
val stored = harness.alarmDao.stored.single()
|
||||
assertThat(TimeOfDay(stored.hour, stored.minute) to harness.scheduler.next?.fireAt)
|
||||
.isEqualTo(TimeOfDay(6, 45) to offsetInstant("2026-06-10T06:45+02:00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an out-of-range time is clamped, never thrown`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith(hour = 7)
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onTimePicked(31, 99)
|
||||
advanceUntilIdle()
|
||||
|
||||
val stored = harness.alarmDao.stored.single()
|
||||
assertThat(TimeOfDay(stored.hour, stored.minute)).isEqualTo(TimeOfDay(23, 59))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toggling a day adds it and toggling again removes it`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith()
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onDayToggled(DayOfWeek.TUESDAY)
|
||||
advanceUntilIdle()
|
||||
val afterOn = viewModel.editing().repeatDays
|
||||
viewModel.onDayToggled(DayOfWeek.TUESDAY)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat((DayOfWeek.TUESDAY in afterOn) to (DayOfWeek.TUESDAY in viewModel.editing().repeatDays))
|
||||
.isEqualTo(true to false)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clearing the last repeat day makes skipping impossible`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith(repeatDays = RepeatDays.of(DayOfWeek.TUESDAY))
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onDayToggled(DayOfWeek.TUESDAY)
|
||||
advanceUntilIdle()
|
||||
|
||||
val editing = viewModel.editing()
|
||||
assertThat(editing.repeatDays to editing.canSkipNext).isEqualTo(RepeatDays.NONE to false)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clearing the last repeat day disarms the skip instead of disabling the alarm`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith(hour = 7, repeatDays = RepeatDays.of(DayOfWeek.TUESDAY))
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
viewModel.onSkipNextChanged(true)
|
||||
advanceUntilIdle()
|
||||
check(harness.alarmDao.stored.single().skipNextOccurrence)
|
||||
|
||||
viewModel.onDayToggled(DayOfWeek.TUESDAY)
|
||||
advanceUntilIdle()
|
||||
|
||||
// A skip left armed on a mask that no longer repeats makes the resolver
|
||||
// disable the alarm — silently, because `canSkipNext` has just hidden
|
||||
// the control that would explain it.
|
||||
val stored = harness.alarmDao.stored.single()
|
||||
assertThat(listOf(stored.enabled, stored.skipNextOccurrence, stored.repeatDays))
|
||||
.containsExactly(true, false, RepeatDays.NONE.mask)
|
||||
.inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `adding a repeat day makes skipping possible`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith()
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
check(!viewModel.editing().canSkipNext)
|
||||
|
||||
viewModel.onDayToggled(DayOfWeek.TUESDAY)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(viewModel.editing().canSkipNext).isTrue()
|
||||
}
|
||||
|
||||
// --- label ---
|
||||
|
||||
@Test
|
||||
fun `a typed label is visible at once and stored`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith(label = "Gym")
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onLabelTyped("Gy")
|
||||
val shown = viewModel.editing().label
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(shown to harness.alarmDao.stored.single().label).isEqualTo("Gy" to "Gy")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rapid typing stores the last keystroke, in order`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith(label = "")
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onLabelTyped("G")
|
||||
viewModel.onLabelTyped("Gy")
|
||||
viewModel.onLabelTyped("Gym")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(harness.alarmDao.stored.single().label).isEqualTo("Gym")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `leaving the editor does not lose the last keystroke`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith(label = "")
|
||||
val viewModel = editor(harness)
|
||||
|
||||
viewModel.onLabelTyped("Gym")
|
||||
viewModel.onCleared()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(harness.alarmDao.stored.single().label).isEqualTo("Gym")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a keystroke waits on no provider round-trip`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith(label = "Gym", ringtoneUri = "content://media/1")
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
check(viewModel.editing().ringtone.title == "Argon")
|
||||
val queriesAfterLoad = catalog.metadataCalls
|
||||
|
||||
// The provider now blocks. The label field feeds the state straight
|
||||
// back into the text field, so a render that awaits the catalog per
|
||||
// keystroke makes the field lag the IME.
|
||||
catalog.titleGate = CompletableDeferred()
|
||||
viewModel.onLabelTyped("Gy")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(viewModel.editing().label to catalog.metadataCalls)
|
||||
.isEqualTo("Gy" to queriesAfterLoad)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a label change moves no registration`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith(hour = 7)
|
||||
harness.engine.reschedule()
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
val registrations = harness.scheduler.scheduleNextCalls
|
||||
|
||||
viewModel.onLabelTyped("Gym")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(harness.scheduler.scheduleNextCalls to harness.scheduler.next?.fireAt)
|
||||
.isEqualTo(registrations to sevenToday)
|
||||
}
|
||||
|
||||
// --- overrides ---
|
||||
|
||||
@Test
|
||||
fun `a stored false override is a choice, not an absence`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith()
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onVibrateChanged(false)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(harness.alarmDao.stored.single().vibrate to viewModel.editing().vibrate)
|
||||
.isEqualTo(false to OverrideValue(false, inherited = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `choosing app default clears the override`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith(vibrate = false)
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onVibrateChanged(null)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(harness.alarmDao.stored.single().vibrate to viewModel.editing().vibrate)
|
||||
.isEqualTo(null to OverrideValue(true, inherited = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `choosing silent stores the sentinel`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith()
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onRingtoneChosen(Ringtones.SILENT_URI)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(harness.alarmDao.stored.single().ringtoneUri to viewModel.editing().ringtone.silent)
|
||||
.isEqualTo(Ringtones.SILENT_URI to true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a ringtone change moves no registration`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith(hour = 7)
|
||||
harness.engine.reschedule()
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
val registrations = harness.scheduler.scheduleNextCalls
|
||||
|
||||
viewModel.onRingtoneChosen("content://media/2")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(harness.scheduler.scheduleNextCalls to harness.scheduler.next?.fireAt)
|
||||
.isEqualTo(registrations to sevenToday)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unreadable sound is reported, never rewritten`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
catalog.unplayable = setOf("content://media/9")
|
||||
harness.givenAlarmWith(ringtoneUri = "content://media/9")
|
||||
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
val ringtone = viewModel.editing().ringtone
|
||||
assertThat(listOf(ringtone.unavailable, ringtone.storedUri, harness.alarmDao.stored.single().ringtoneUri))
|
||||
.containsExactly(true, "content://media/9", "content://media/9")
|
||||
.inOrder()
|
||||
}
|
||||
|
||||
// --- the SAF pick ---
|
||||
|
||||
@Test
|
||||
fun `a picked document's grant is taken and its uri stored`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
catalog.canPersist = true
|
||||
harness.givenAlarmWith()
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onDocumentPicked("content://docs/a")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(
|
||||
listOf(
|
||||
catalog.persisted,
|
||||
harness.alarmDao.stored.single().ringtoneUri,
|
||||
viewModel.editing().ringtone.accessMayLapse,
|
||||
),
|
||||
).containsExactly(listOf("content://docs/a"), "content://docs/a", false).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a grant that cannot be made persistent is disclosed, not refused`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
catalog.canPersist = false
|
||||
harness.givenAlarmWith()
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onDocumentPicked("content://docs/a")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(
|
||||
harness.alarmDao.stored.single().ringtoneUri to viewModel.editing().ringtone.accessMayLapse,
|
||||
).isEqualTo("content://docs/a" to true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cancelled pick changes nothing`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith()
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
val before = viewModel.state.value
|
||||
|
||||
viewModel.onDocumentPicked(null)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(harness.alarmDao.stored.single().ringtoneUri to viewModel.state.value)
|
||||
.isEqualTo(null to before)
|
||||
}
|
||||
|
||||
// --- the picker and the preview ---
|
||||
|
||||
@Test
|
||||
fun `opening the picker loads the device's tones once`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith()
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
check(viewModel.ringtonePicker.value == null)
|
||||
|
||||
viewModel.onOpenRingtonePicker()
|
||||
val whileLoading = viewModel.ringtonePicker.value
|
||||
advanceUntilIdle()
|
||||
|
||||
val settled = viewModel.ringtonePicker.value
|
||||
assertThat(
|
||||
listOf(
|
||||
whileLoading?.loading,
|
||||
settled?.loading,
|
||||
settled?.options?.filterIsInstance<RingtoneChoice.Sound>()?.map { it.uri },
|
||||
catalog.alarmTonesCalls,
|
||||
),
|
||||
).containsExactly(
|
||||
true,
|
||||
false,
|
||||
catalog.tones.map { it.uri },
|
||||
1,
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a device with no sounds still offers the stored one`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
catalog.tones = emptyList()
|
||||
catalog.titles = mapOf("content://docs/a" to "Mine")
|
||||
harness.givenAlarmWith(ringtoneUri = "content://docs/a")
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onOpenRingtonePicker()
|
||||
advanceUntilIdle()
|
||||
|
||||
val picker = viewModel.ringtonePicker.value
|
||||
assertThat(
|
||||
picker?.options?.filterIsInstance<RingtoneChoice.Sound>()?.map { it.uri } to
|
||||
picker?.deviceHasNoSounds,
|
||||
).isEqualTo(listOf("content://docs/a") to true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `choosing an option stops the preview and closes the picker`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith()
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
viewModel.onOpenRingtonePicker()
|
||||
viewModel.onPreview("content://media/1")
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onRingtoneChosen("content://media/1")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(previewer.events.last() to viewModel.ringtonePicker.value)
|
||||
.isEqualTo(PreviewEvent.Stopped to null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a second preview stops the first`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith()
|
||||
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()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `previewing nothing or silence only stops`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith()
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onPreview(null)
|
||||
viewModel.onPreview(Ringtones.SILENT_URI)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(previewer.events.filterIsInstance<PreviewEvent.Played>() to previewer.events.isNotEmpty())
|
||||
.isEqualTo(emptyList<PreviewEvent.Played>() to true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `leaving the editor stops the preview`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith()
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
viewModel.onPreview("content://media/1")
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onCleared()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(previewer.events.last()).isEqualTo(PreviewEvent.Stopped)
|
||||
}
|
||||
|
||||
// --- skip ---
|
||||
|
||||
@Test
|
||||
fun `arming a skip moves the registration to the occurrence after it`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith(hour = 7, repeatDays = RepeatDays.EVERY_DAY)
|
||||
harness.engine.reschedule()
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onSkipNextChanged(true)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(
|
||||
listOf(
|
||||
harness.alarmDao.stored.single().skipNextOccurrence,
|
||||
harness.scheduler.next?.fireAt,
|
||||
viewModel.editing().skipNextOccurrence,
|
||||
),
|
||||
).containsExactly(true, sevenTomorrow, true).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a one-shot alarm cannot skip its only occurrence`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith(hour = 7, repeatDays = RepeatDays.NONE)
|
||||
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(viewModel.editing().canSkipNext).isFalse()
|
||||
}
|
||||
|
||||
// --- delete ---
|
||||
|
||||
@Test
|
||||
fun `deleting the alarm leaves the editor gone`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith(hour = 7)
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onDelete()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(harness.alarmDao.stored to viewModel.state.value)
|
||||
.isEqualTo(emptyList<Any>() to AlarmEditorUiState.Gone)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deleting the ringing alarm silences it first`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith(hour = 7)
|
||||
harness.givenRinging(alarmId = 1L, at = sevenToday)
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onDelete()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(harness.ring.events.last() to harness.alarmDao.stored)
|
||||
.isEqualTo(RingEvent.Stopped(1L) to emptyList<Any>())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deleting the last alarm clears the slot`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith(hour = 7)
|
||||
harness.engine.reschedule()
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
check(harness.scheduler.next != null)
|
||||
|
||||
viewModel.onDelete()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(harness.scheduler.next).isNull()
|
||||
}
|
||||
|
||||
// --- concurrency ---
|
||||
|
||||
@Test
|
||||
fun `two changes in a row both survive`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith(hour = 7)
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onSnoozeLimitChanged(1)
|
||||
viewModel.onTimePicked(6, 45)
|
||||
advanceUntilIdle()
|
||||
|
||||
val stored = harness.alarmDao.stored.single()
|
||||
assertThat(stored.snoozeLimit to TimeOfDay(stored.hour, stored.minute))
|
||||
.isEqualTo(1 to TimeOfDay(6, 45))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an engine disable underneath the editor is not clobbered`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = fiveToday)
|
||||
harness.givenAlarmWith(hour = 7)
|
||||
val viewModel = editor(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
harness.alarms.setEnabled(1L, enabled = false)
|
||||
viewModel.onVolumeRampChanged(30)
|
||||
advanceUntilIdle()
|
||||
|
||||
val stored = harness.alarmDao.stored.single()
|
||||
assertThat(stored.volumeRampSeconds to stored.enabled).isEqualTo(30 to false)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package de.jeanlucmakiola.clockula.ui.alarms
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.clockula.domain.RepeatDays
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.FireSource
|
||||
import de.jeanlucmakiola.clockula.domain.format.NextFireLabel
|
||||
import de.jeanlucmakiola.clockula.testing.offsetInstant
|
||||
import de.jeanlucmakiola.clockula.testing.upcomingAlarm
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
/**
|
||||
* The list is ordered by **time of day**, not by the engine's next-fire order:
|
||||
* an 07:00 and a 22:00 alarm would otherwise swap places at 07:00, reordering
|
||||
* rows under the user's thumb (M5 D13).
|
||||
*/
|
||||
class AlarmListRowsTest {
|
||||
|
||||
private val now = offsetInstant("2026-06-10T20:00+02:00")
|
||||
private val sevenTomorrow = offsetInstant("2026-06-11T07:00+02:00")
|
||||
private val tenTonight = offsetInstant("2026-06-10T22:00+02:00")
|
||||
|
||||
@Test
|
||||
fun `rows are ordered by time of day, not by next fire`() {
|
||||
val upcoming = listOf(
|
||||
upcomingAlarm(id = 2L, hour = 7, nextFire = sevenTomorrow),
|
||||
upcomingAlarm(id = 1L, hour = 22, nextFire = tenTonight),
|
||||
)
|
||||
|
||||
val rows = AlarmListRows.from(upcoming, now)
|
||||
|
||||
assertThat(rows.map { it.id }).containsExactly(2L, 1L).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a disabled alarm keeps its place in the middle`() {
|
||||
val upcoming = listOf(
|
||||
upcomingAlarm(id = 1L, hour = 7, nextFire = sevenTomorrow),
|
||||
upcomingAlarm(id = 2L, hour = 12, enabled = false, nextFire = null, source = FireSource.NONE),
|
||||
upcomingAlarm(id = 3L, hour = 22, nextFire = tenTonight),
|
||||
)
|
||||
|
||||
val rows = AlarmListRows.from(upcoming, now)
|
||||
|
||||
assertThat(rows.map { it.id }).containsExactly(1L, 2L, 3L).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the earliest next fire is the next alarm`() {
|
||||
val upcoming = listOf(
|
||||
upcomingAlarm(id = 1L, hour = 6, nextFire = now + 2.hours),
|
||||
upcomingAlarm(id = 2L, hour = 7, nextFire = now + 30.minutes),
|
||||
upcomingAlarm(id = 3L, hour = 8, enabled = false, nextFire = null, source = FireSource.NONE),
|
||||
)
|
||||
|
||||
val rows = AlarmListRows.from(upcoming, now)
|
||||
|
||||
assertThat(rows.filter { it.isNext }.map { it.id }).containsExactly(2L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two alarms at the same next fire leave the mark on the lower id`() {
|
||||
val upcoming = listOf(
|
||||
upcomingAlarm(id = 9L, hour = 7, nextFire = now + 30.minutes),
|
||||
upcomingAlarm(id = 4L, hour = 8, nextFire = now + 30.minutes),
|
||||
)
|
||||
|
||||
val rows = AlarmListRows.from(upcoming, now)
|
||||
|
||||
assertThat(rows.filter { it.isNext }.map { it.id }).containsExactly(4L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `with nothing scheduled no row is the next one`() {
|
||||
val upcoming = listOf(
|
||||
upcomingAlarm(id = 1L, hour = 7, enabled = false, nextFire = null, source = FireSource.NONE),
|
||||
upcomingAlarm(id = 2L, hour = 8, enabled = false, nextFire = null, source = FireSource.NONE),
|
||||
)
|
||||
|
||||
val rows = AlarmListRows.from(upcoming, now)
|
||||
|
||||
assertThat(rows.none { it.isNext }).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a ringing alarm is happening, not coming`() {
|
||||
val upcoming = listOf(
|
||||
upcomingAlarm(id = 1L, hour = 7, nextFire = null, source = FireSource.RESUMED_RING),
|
||||
)
|
||||
|
||||
val row = AlarmListRows.from(upcoming, now).single()
|
||||
|
||||
assertThat(listOf(row.ringing, row.isNext, row.nextFire))
|
||||
.containsExactly(true, false, NextFireLabel.NotScheduled)
|
||||
.inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a snoozed alarm counts down to its snooze instant`() {
|
||||
val upcoming = listOf(
|
||||
upcomingAlarm(id = 1L, hour = 7, nextFire = now + 7.minutes, source = FireSource.SNOOZE),
|
||||
)
|
||||
|
||||
val row = AlarmListRows.from(upcoming, now).single()
|
||||
|
||||
assertThat(row.snoozed to row.nextFire).isEqualTo(true to NextFireLabel.InMinutes(7))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only a repeating alarm can be skipping its next occurrence`() {
|
||||
val repeating = upcomingAlarm(
|
||||
id = 1L,
|
||||
hour = 7,
|
||||
repeatDays = RepeatDays.EVERY_DAY,
|
||||
skipNextOccurrence = true,
|
||||
nextFire = sevenTomorrow,
|
||||
)
|
||||
val oneShot = upcomingAlarm(id = 2L, hour = 8, skipNextOccurrence = true, nextFire = sevenTomorrow)
|
||||
|
||||
val rows = AlarmListRows.from(listOf(repeating, oneShot), now)
|
||||
|
||||
assertThat(rows.map { it.skippingNext }).containsExactly(true, false).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a disabled alarm reads as not scheduled`() {
|
||||
val upcoming = listOf(
|
||||
upcomingAlarm(id = 1L, hour = 7, enabled = false, nextFire = null, source = FireSource.NONE),
|
||||
)
|
||||
|
||||
val row = AlarmListRows.from(upcoming, now).single()
|
||||
|
||||
assertThat(row.nextFire).isEqualTo(NextFireLabel.NotScheduled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank label is carried through blank`() {
|
||||
val upcoming = listOf(upcomingAlarm(id = 1L, hour = 7, label = "", nextFire = sevenTomorrow))
|
||||
|
||||
val row = AlarmListRows.from(upcoming, now).single()
|
||||
|
||||
assertThat(row.label).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no alarms is an empty list, not a throw`() {
|
||||
val rows = AlarmListRows.from(emptyList(), now)
|
||||
|
||||
assertThat(rows).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the repeat mask is carried through raw for the locale to summarise`() {
|
||||
val upcoming = listOf(
|
||||
upcomingAlarm(id = 1L, hour = 7, repeatDays = RepeatDays.WEEKDAYS, nextFire = sevenTomorrow),
|
||||
)
|
||||
|
||||
val row = AlarmListRows.from(upcoming, now).single()
|
||||
|
||||
assertThat(row.repeatDays).isEqualTo(RepeatDays.WEEKDAYS)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `alarms at the same time of day are ordered by id`() {
|
||||
val upcoming = listOf(
|
||||
upcomingAlarm(id = 9L, hour = 7, nextFire = sevenTomorrow),
|
||||
upcomingAlarm(id = 4L, hour = 7, nextFire = sevenTomorrow),
|
||||
)
|
||||
|
||||
val rows = AlarmListRows.from(upcoming, now)
|
||||
|
||||
assertThat(rows.map { it.id }).containsExactly(4L, 9L).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every countdown is measured against the one now it was given`() {
|
||||
val upcoming = listOf(
|
||||
upcomingAlarm(id = 1L, hour = 7, nextFire = now + 30.minutes),
|
||||
upcomingAlarm(id = 2L, hour = 8, nextFire = now + 90.minutes),
|
||||
)
|
||||
|
||||
val rows = AlarmListRows.from(upcoming, now)
|
||||
|
||||
assertThat(rows.map { it.nextFire })
|
||||
.containsExactly(NextFireLabel.InMinutes(30), NextFireLabel.InHoursMinutes(1, 30))
|
||||
.inOrder()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package de.jeanlucmakiola.clockula.ui.alarms
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* A per-alarm setting is ternary: absent, or a stored choice that happens to be
|
||||
* `false` or `0`. Nothing may collapse the two (M5 D16).
|
||||
*/
|
||||
class AlarmOverridesTest {
|
||||
|
||||
@Test
|
||||
fun `an absent override shows the default and says so`() {
|
||||
val override = AlarmOverrides.of(stored = null, default = 10)
|
||||
|
||||
assertThat(override).isEqualTo(OverrideValue(10, inherited = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stored value wins over the default`() {
|
||||
val override = AlarmOverrides.of(stored = 7, default = 10)
|
||||
|
||||
assertThat(override).isEqualTo(OverrideValue(7, inherited = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stored false is a choice, not an absence`() {
|
||||
val override = AlarmOverrides.of(stored = false, default = true)
|
||||
|
||||
assertThat(override).isEqualTo(OverrideValue(false, inherited = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stored zero is a choice, not an absence`() {
|
||||
val override = AlarmOverrides.of(stored = 0, default = 3)
|
||||
|
||||
assertThat(override).isEqualTo(OverrideValue(0, inherited = false))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package de.jeanlucmakiola.clockula.ui.alarms
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.clockula.ui.shell.ShellBackAction
|
||||
import de.jeanlucmakiola.clockula.ui.shell.ShellNavigation
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* 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 (M5 D1).
|
||||
*/
|
||||
class AlarmRoutesTest {
|
||||
|
||||
@Test
|
||||
fun `the editor route carries the alarm's id`() {
|
||||
assertThat(AlarmRoutes.editor(7L)).isEqualTo("alarms/edit/7")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the pattern's placeholder is the declared argument name`() {
|
||||
assertThat(AlarmRoutes.EDITOR_PATTERN).contains("{" + AlarmRoutes.ARG_ALARM_ID + "}")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the editor is no tab, so the alarms tab stays selected`() {
|
||||
assertThat(ShellNavigation.destinationOf(AlarmRoutes.editor(7L))).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the shell stands aside on back, leaving the editor to pop itself`() {
|
||||
assertThat(ShellNavigation.onBack(AlarmRoutes.editor(7L))).isEqualTo(ShellBackAction.Exit)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
package de.jeanlucmakiola.clockula.ui.alarms
|
||||
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.clockula.domain.RepeatDays
|
||||
import de.jeanlucmakiola.clockula.domain.TimeOfDay
|
||||
import de.jeanlucmakiola.clockula.domain.format.NextFireLabel
|
||||
import de.jeanlucmakiola.clockula.testing.AlarmEngineHarness
|
||||
import de.jeanlucmakiola.clockula.testing.BERLIN
|
||||
import de.jeanlucmakiola.clockula.testing.MainDispatcherExtension
|
||||
import de.jeanlucmakiola.clockula.testing.RingEvent
|
||||
import de.jeanlucmakiola.clockula.testing.alarmEngineHarness
|
||||
import de.jeanlucmakiola.clockula.testing.instantAt
|
||||
import de.jeanlucmakiola.clockula.testing.offsetInstant
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
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 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.Companion.minutes
|
||||
|
||||
/**
|
||||
* The list over the **real** alarm engine: what the rows say, that the readout
|
||||
* advances on a tick without a repository write, and that switching off a
|
||||
* *ringing* alarm silences it first (M5 D11) instead of leaving it sounding
|
||||
* until the wake lock expires.
|
||||
*/
|
||||
class AlarmsViewModelTest {
|
||||
|
||||
@JvmField
|
||||
@RegisterExtension
|
||||
val main = MainDispatcherExtension()
|
||||
|
||||
private val sevenToday = offsetInstant("2026-06-10T07:00+02:00")
|
||||
|
||||
/**
|
||||
* The list's state is shared `WhileSubscribed`, so that the 30-second ticker
|
||||
* upstream is not left waking with nobody looking. A collector on the
|
||||
* background scope stands in for the screen's own
|
||||
* `collectAsStateWithLifecycle`; without one there is no subscriber and
|
||||
* nothing upstream would run.
|
||||
*/
|
||||
private fun TestScope.viewModel(harness: AlarmEngineHarness) = AlarmsViewModel(
|
||||
alarms = harness.alarms,
|
||||
engine = harness.engine,
|
||||
wall = harness.wallClock,
|
||||
zones = harness.zones,
|
||||
source = AlarmListSource(harness.engine, harness.wallClock, harness.ticker),
|
||||
).also { viewModel -> backgroundScope.launch { viewModel.state.collect {} } }
|
||||
|
||||
private fun AlarmsUiState.rows(): List<AlarmRowState> = (this as AlarmsUiState.Loaded).rows
|
||||
|
||||
@Test
|
||||
fun `the list starts out loading and settles on what was stored`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
val harness = alarmEngineHarness(tempDir)
|
||||
harness.givenAlarm(id = 1L, hour = 7)
|
||||
|
||||
val viewModel = viewModel(harness)
|
||||
val before = viewModel.state.value
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(before to viewModel.state.value.rows().size)
|
||||
.isEqualTo(AlarmsUiState.Loading to 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty store settles on a real empty state, not on loading`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir)
|
||||
|
||||
val viewModel = viewModel(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(viewModel.state.value).isEqualTo(AlarmsUiState.Loaded(emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `adding an alarm stores an enabled one-shot at the next whole hour`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = instantAt(BERLIN, "2026-06-10T06:12"))
|
||||
val viewModel = viewModel(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.openEditor.test {
|
||||
viewModel.onAddAlarm()
|
||||
val newId = awaitItem()
|
||||
advanceUntilIdle()
|
||||
|
||||
val stored = harness.alarmDao.stored.single()
|
||||
assertThat(
|
||||
listOf(
|
||||
stored.id,
|
||||
newId,
|
||||
stored.enabled,
|
||||
stored.repeatDays,
|
||||
stored.label,
|
||||
TimeOfDay(stored.hour, stored.minute),
|
||||
listOf(
|
||||
stored.ringtoneUri,
|
||||
stored.vibrate,
|
||||
stored.snoozeMinutes,
|
||||
stored.snoozeLimit,
|
||||
stored.volumeRampSeconds,
|
||||
stored.dismissChallenge,
|
||||
),
|
||||
),
|
||||
).containsExactly(
|
||||
newId,
|
||||
newId,
|
||||
true,
|
||||
RepeatDays.NONE.mask,
|
||||
"",
|
||||
TimeOfDay(7, 0),
|
||||
listOf(null, null, null, null, null, null),
|
||||
).inOrder()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a double-tapped plus creates one alarm and opens one editor`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
Dispatchers.setMain(StandardTestDispatcher(testScheduler))
|
||||
val harness = alarmEngineHarness(tempDir, now = instantAt(BERLIN, "2026-06-10T06:12"))
|
||||
val viewModel = viewModel(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.openEditor.test {
|
||||
viewModel.onAddAlarm()
|
||||
viewModel.onAddAlarm()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Two ids would leave the stack `alarms -> editor(A) -> editor(B)`,
|
||||
// with the user on the second alarm and the first behind back.
|
||||
val opened = awaitItem()
|
||||
expectNoEvents()
|
||||
assertThat(harness.alarmDao.stored.map { it.id }).containsExactly(opened)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a new alarm is registered with the system at once`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = instantAt(BERLIN, "2026-06-10T06:12"))
|
||||
val viewModel = viewModel(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onAddAlarm()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(harness.scheduler.next?.fireAt).isEqualTo(sevenToday)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `switching an alarm off stores it disabled and clears the slot`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir)
|
||||
harness.givenAlarm(id = 1L, hour = 7)
|
||||
harness.engine.reschedule()
|
||||
val viewModel = viewModel(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onToggleEnabled(1L, enabled = false)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(harness.alarmDao.stored.single().enabled to harness.scheduler.next)
|
||||
.isEqualTo(false to null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `switching an alarm on registers its next fire again`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir)
|
||||
harness.givenAlarm(id = 1L, hour = 7, enabled = false)
|
||||
harness.engine.reschedule()
|
||||
val viewModel = viewModel(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onToggleEnabled(1L, enabled = true)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(harness.scheduler.next?.fireAt).isEqualTo(sevenToday)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `switching off the ringing alarm silences it first`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir)
|
||||
harness.givenAlarm(id = 1L, hour = 7)
|
||||
harness.givenRinging(alarmId = 1L, at = sevenToday)
|
||||
val viewModel = viewModel(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onToggleEnabled(1L, enabled = false)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(
|
||||
listOf(
|
||||
harness.ring.events.last(),
|
||||
harness.ring.ringing.value,
|
||||
harness.scheduler.autoSilence,
|
||||
harness.alarmDao.stored.single().enabled,
|
||||
),
|
||||
).containsExactly(RingEvent.Stopped(1L), null, null, false).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `switching off another alarm leaves the ringing one ringing`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir)
|
||||
harness.givenAlarm(id = 1L, hour = 7)
|
||||
harness.givenAlarm(id = 2L, hour = 9)
|
||||
harness.givenRinging(alarmId = 1L, at = sevenToday)
|
||||
val viewModel = viewModel(harness)
|
||||
advanceUntilIdle()
|
||||
val ringEvents = harness.ring.events.toList()
|
||||
|
||||
viewModel.onToggleEnabled(2L, enabled = false)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(
|
||||
listOf(
|
||||
harness.ring.events,
|
||||
harness.states.state(1L).ringingSince != null,
|
||||
harness.scheduler.autoSilence != null,
|
||||
),
|
||||
).containsExactly(ringEvents, true, true).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `switching off a snoozed alarm drops the snooze without stopping a ring`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir)
|
||||
harness.givenAlarm(id = 1L, hour = 7, repeatDays = RepeatDays.EVERY_DAY)
|
||||
harness.givenRinging(alarmId = 1L, at = sevenToday)
|
||||
harness.engine.snooze(1L)
|
||||
val viewModel = viewModel(harness)
|
||||
advanceUntilIdle()
|
||||
val ringEvents = harness.ring.events.toList()
|
||||
|
||||
viewModel.onToggleEnabled(1L, enabled = false)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(harness.states.state(1L).snoozedUntil to harness.ring.events)
|
||||
.isEqualTo(null to ringEvents)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the countdown advances on a tick, with no repository write at all`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = instantAt(BERLIN, "2026-06-10T06:00"))
|
||||
harness.givenAlarm(id = 1L, hour = 7)
|
||||
val viewModel = viewModel(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.state.test {
|
||||
assertThat(awaitItem().rows().single().nextFire)
|
||||
.isEqualTo(NextFireLabel.InHoursMinutes(1, 0))
|
||||
|
||||
harness.wallClock.advance(10.minutes)
|
||||
harness.ticker.tick()
|
||||
|
||||
assertThat(awaitItem().rows().single().nextFire)
|
||||
.isEqualTo(NextFireLabel.InMinutes(50))
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a tick that changes nothing does not re-emit`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = instantAt(BERLIN, "2026-06-10T06:00"))
|
||||
harness.givenAlarm(id = 1L, hour = 7)
|
||||
val viewModel = viewModel(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.state.test {
|
||||
awaitItem()
|
||||
|
||||
harness.ticker.tick()
|
||||
advanceUntilIdle()
|
||||
|
||||
expectNoEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a skipped occurrence counts down to the one after it`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = instantAt(BERLIN, "2026-06-10T05:00"))
|
||||
harness.givenAlarm(
|
||||
id = 1L,
|
||||
hour = 7,
|
||||
repeatDays = RepeatDays.EVERY_DAY,
|
||||
skipNextOccurrence = true,
|
||||
)
|
||||
val viewModel = viewModel(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(viewModel.state.value.rows().single().nextFire)
|
||||
.isEqualTo(NextFireLabel.InDaysHours(1, 2))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reading the list, ticks included, writes no ring state`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir, now = instantAt(BERLIN, "2026-06-10T06:00"))
|
||||
harness.givenAlarm(id = 1L, hour = 7, repeatDays = RepeatDays.EVERY_DAY)
|
||||
val viewModel = viewModel(harness)
|
||||
advanceUntilIdle()
|
||||
val writesBefore = harness.stateDao.writes
|
||||
|
||||
viewModel.state.test {
|
||||
awaitItem()
|
||||
repeat(3) {
|
||||
harness.wallClock.advance(10.minutes)
|
||||
harness.ticker.tick()
|
||||
advanceUntilIdle()
|
||||
}
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
|
||||
assertThat(harness.stateDao.writes).isEqualTo(writesBefore)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an alarm deleted underneath the list disappears from it`(@TempDir tempDir: Path) =
|
||||
runTest(main.dispatcher) {
|
||||
val harness = alarmEngineHarness(tempDir)
|
||||
harness.givenAlarm(id = 1L, hour = 7)
|
||||
harness.givenAlarm(id = 2L, hour = 9)
|
||||
val viewModel = viewModel(harness)
|
||||
advanceUntilIdle()
|
||||
|
||||
harness.alarms.delete(1L)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(viewModel.state.value.rows().map { it.id }).containsExactly(2L)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package de.jeanlucmakiola.clockula.ui.alarms
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.google.common.truth.Truth.assertWithMessage
|
||||
import de.jeanlucmakiola.clockula.domain.RingtoneOption
|
||||
import de.jeanlucmakiola.clockula.domain.Ringtones
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* The option list is pure: App default, Silent, the user's own sound when the
|
||||
* device list does not contain it, then the device's own tones (M5 D17). A
|
||||
* picker is never empty, and a SAF-picked file is never invisible.
|
||||
*/
|
||||
class RingtonePickerTest {
|
||||
|
||||
private val tones = listOf(
|
||||
RingtoneOption("content://media/1", "Argon"),
|
||||
RingtoneOption("content://media/2", "Bright morning"),
|
||||
RingtoneOption("content://media/3", "Cesium"),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `app default and silent always lead the list`() {
|
||||
val options = RingtonePicker.optionsFor(tones, storedUri = "content://media/2", storedTitle = "Bright morning")
|
||||
|
||||
assertThat(options.take(2))
|
||||
.containsExactly(RingtoneChoice.AppDefault, RingtoneChoice.Silent)
|
||||
.inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the device's tones follow in the catalog's order`() {
|
||||
val options = RingtonePicker.optionsFor(tones, storedUri = null, storedTitle = null)
|
||||
|
||||
assertThat(options.drop(2)).containsExactly(
|
||||
RingtoneChoice.Sound("content://media/1", "Argon"),
|
||||
RingtoneChoice.Sound("content://media/2", "Bright morning"),
|
||||
RingtoneChoice.Sound("content://media/3", "Cesium"),
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a sound the device does not list sits directly under silent`() {
|
||||
val options = RingtonePicker.optionsFor(
|
||||
tones,
|
||||
storedUri = "content://docs/mine.ogg",
|
||||
storedTitle = "Mine",
|
||||
)
|
||||
|
||||
assertThat(options[2]).isEqualTo(RingtoneChoice.Sound("content://docs/mine.ogg", "Mine"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a stored sound the device already lists is not duplicated`() {
|
||||
val options = RingtonePicker.optionsFor(
|
||||
tones,
|
||||
storedUri = "content://media/2",
|
||||
storedTitle = "Bright morning",
|
||||
)
|
||||
|
||||
assertThat(options.filterIsInstance<RingtoneChoice.Sound>().map { it.uri })
|
||||
.containsExactly("content://media/1", "content://media/2", "content://media/3")
|
||||
.inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the silent sentinel adds no sound row of its own`() {
|
||||
val options = RingtonePicker.optionsFor(tones, storedUri = Ringtones.SILENT_URI, storedTitle = null)
|
||||
|
||||
assertThat(options.filterIsInstance<RingtoneChoice.Sound>()).hasSize(tones.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no stored override adds no sound row of its own`() {
|
||||
val options = RingtonePicker.optionsFor(tones, storedUri = null, storedTitle = null)
|
||||
|
||||
assertThat(options.filterIsInstance<RingtoneChoice.Sound>()).hasSize(tones.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a device with no alarm sounds still offers two choices`() {
|
||||
val options = RingtonePicker.optionsFor(emptyList(), storedUri = null, storedTitle = null)
|
||||
|
||||
assertThat(options)
|
||||
.containsExactly(RingtoneChoice.AppDefault, RingtoneChoice.Silent)
|
||||
.inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a sound the device cannot name gets a blank title`() {
|
||||
val options = RingtonePicker.optionsFor(
|
||||
tones,
|
||||
storedUri = "content://docs/mine.ogg",
|
||||
storedTitle = null,
|
||||
)
|
||||
|
||||
assertThat(options[2]).isEqualTo(RingtoneChoice.Sound("content://docs/mine.ogg", ""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a duplicated device tone appears once, the first occurrence winning`() {
|
||||
val duplicated = tones + RingtoneOption("content://media/1", "Argon (copy)")
|
||||
|
||||
val options = RingtonePicker.optionsFor(duplicated, storedUri = null, storedTitle = null)
|
||||
|
||||
assertThat(options.filterIsInstance<RingtoneChoice.Sound>()).containsExactly(
|
||||
RingtoneChoice.Sound("content://media/1", "Argon"),
|
||||
RingtoneChoice.Sound("content://media/2", "Bright morning"),
|
||||
RingtoneChoice.Sound("content://media/3", "Cesium"),
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exactly one option is checked for each stored shape`() {
|
||||
val sound = RingtoneChoice.Sound("content://media/2", "Bright morning")
|
||||
val options = listOf(RingtoneChoice.AppDefault, RingtoneChoice.Silent, sound)
|
||||
val stored = listOf(null, Ringtones.SILENT_URI, "content://media/2")
|
||||
val expected = listOf(
|
||||
listOf(true, false, false),
|
||||
listOf(false, true, false),
|
||||
listOf(false, false, true),
|
||||
)
|
||||
|
||||
stored.forEachIndexed { index, storedUri ->
|
||||
assertWithMessage("stored %s", storedUri)
|
||||
.that(options.map { RingtonePicker.isSelected(it, storedUri) })
|
||||
.isEqualTo(expected[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user