diff --git a/app/src/androidTest/java/de/jeanlucmakiola/clockula/ui/AlarmsInstrumentedTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/clockula/ui/AlarmsInstrumentedTest.kt new file mode 100644 index 0000000..fa430b9 --- /dev/null +++ b/app/src/androidTest/java/de/jeanlucmakiola/clockula/ui/AlarmsInstrumentedTest.kt @@ -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? = 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 = + ActivityScenario.launch( + 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() + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorScreen.kt new file mode 100644 index 0000000..f87c62e --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorScreen.kt @@ -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(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), + ) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorUiState.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorUiState.kt new file mode 100644 index 0000000..852d31d --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorUiState.kt @@ -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(val value: T, val inherited: Boolean) + +internal object AlarmOverrides { + /** `inherited == (stored == null)`. A stored `false` or `0` is a choice, not an absence. */ + fun of(stored: T?, default: T): OverrideValue = + 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, + val snoozeMinutes: OverrideValue, + val snoozeLimit: OverrideValue, + val volumeRampSeconds: OverrideValue, + val dismissChallenge: OverrideValue, + /** So a picker can label "App default (10 minutes)". */ + val defaults: ClockDefaults, + ) : AlarmEditorUiState +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorViewModel.kt new file mode 100644 index 0000000..5148f72 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorViewModel.kt @@ -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(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(null) + + /** A picked document whose read grant could not be made persistent (D20). */ + private val accessMayLapse = MutableStateFlow(false) + + private val _state = MutableStateFlow(AlarmEditorUiState.Loading) + + val state: StateFlow = _state.asStateFlow() + + private val _ringtonePicker = MutableStateFlow(null) + + /** Null while the picker is closed. */ + val ringtonePicker: StateFlow = _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() + + 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, + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmListSource.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmListSource.kt new file mode 100644 index 0000000..d3eb85a --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmListSource.kt @@ -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> = engine + .upcoming(ticker.ticks(AlarmsDefaults.NextFireTick)) + .map { upcoming -> AlarmListRows.from(upcoming, wall.now()) } + .distinctUntilChanged() +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmRoutes.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmRoutes.kt new file mode 100644 index 0000000..1d58093 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmRoutes.kt @@ -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" +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmRow.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmRow.kt new file mode 100644 index 0000000..5489b4f --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmRow.kt @@ -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() } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmRowState.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmRowState.kt new file mode 100644 index 0000000..bfa9310 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmRowState.kt @@ -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) : AlarmsUiState +} + +internal object AlarmListRows { + /** Pure. Orders by time of day then id; marks `isNext`; never throws. */ + fun from(upcoming: List, now: Instant): List { + // 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, + ) + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmTimePickerDialog.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmTimePickerDialog.kt new file mode 100644 index 0000000..cb0bbef --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmTimePickerDialog.kt @@ -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) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsDefaults.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsDefaults.kt new file mode 100644 index 0000000..f4455f3 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsDefaults.kt @@ -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 +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsScreen.kt index 1b4feba..7360789 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsScreen.kt @@ -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, + ) + } + } } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsViewModel.kt new file mode 100644 index 0000000..a0eb02d --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsViewModel.kt @@ -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 = source.rows() + .map, 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(Channel.CONFLATED) + + /** One-shot: the id of a freshly created alarm, for the caller to navigate to. */ + val openEditor: Flow = 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 diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/OverridePickers.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/OverridePickers.kt new file mode 100644 index 0000000..d4f855a --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/OverridePickers.kt @@ -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 = listOf(null, true, false) + + val VolumeRampSeconds: List = listOf(null, 0, 5, 15, 30, 60) + + val SnoozeMinutes: List = listOf(null, 1, 5, 9, 10, 15, 20, 30) + + val SnoozeLimit: List = listOf(null, 0, 1, 2, 3, 5, 10) + + val DismissChallenges: List = + 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 OverridePicker( + title: String, + options: List, + 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, + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/RepeatDaySelector.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/RepeatDaySelector.kt new file mode 100644 index 0000000..57fac9d --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/RepeatDaySelector.kt @@ -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)) + } + } + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/RingtonePickerScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/RingtonePickerScreen.kt new file mode 100644 index 0000000..6d509d4 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/RingtonePickerScreen.kt @@ -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) }, + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/RingtonePickerState.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/RingtonePickerState.kt new file mode 100644 index 0000000..e67158d --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/alarms/RingtonePickerState.kt @@ -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, + /** 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, + storedUri: String?, + storedTitle: String?, + ): List { + 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 + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ClockulaShell.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ClockulaShell.kt index 327bf89..924e85d 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ClockulaShell.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/shell/ClockulaShell.kt @@ -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() } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2b65dbc..8f46e48 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -49,4 +49,81 @@ Hold to dismiss Dismiss without solving No snoozes left + + + Add alarm + Tap + to set your first alarm + Alarm at %1$s + in less than a minute + + in %1$d min + in %1$d min + + in %1$dh + in %1$dh %2$dm + in %1$dd + in %1$dd %2$dh + Ringing now + Snoozed \u00b7 %1$s + Skipping next + Once + Every day + Weekdays + Weekends + " \u00b7 " + + + Alarm + Repeat + Skip next alarm + Ring the one after this + Alarm name + Sound and vibration + Snooze and dismiss + Sound + Vibrate + Gradual volume + Snooze length + Snooze limit + To dismiss + Delete alarm + Delete this alarm? + The %1$s alarm will be removed. + Delete + Cancel + OK + + + App default + App default (%1$s) + On + Off + No gradual volume + + %1$d second + %1$d seconds + + + %1$d minute + %1$d minutes + + No snoozing + + %1$d time + %1$d times + + Just tap Dismiss + Solve a sum + Press and hold + + + Alarm sound + Silent + Vibration only + Device default alarm sound + Choose from files\u2026 + This device has no alarm sounds. Clockula will use the system default. + Unavailable \u2014 the default alarm sound will be used + Unknown sound + Clockula may lose access to this file later diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ArchitectureRulesTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ArchitectureRulesTest.kt index ae62d7b..73cb2b0 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/ArchitectureRulesTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ArchitectureRulesTest.kt @@ -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 = 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() } } diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorViewModelTest.kt new file mode 100644 index 0000000..659bc7a --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmEditorViewModelTest.kt @@ -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()?.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()?.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() to previewer.events.isNotEmpty()) + .isEqualTo(emptyList() 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() 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()) + } + + @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) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmListRowsTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmListRowsTest.kt new file mode 100644 index 0000000..eb3aaf0 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmListRowsTest.kt @@ -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() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmOverridesTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmOverridesTest.kt new file mode 100644 index 0000000..e041f0c --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmOverridesTest.kt @@ -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)) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmRoutesTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmRoutesTest.kt new file mode 100644 index 0000000..989b2ce --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmRoutesTest.kt @@ -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) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsViewModelTest.kt new file mode 100644 index 0000000..a8f3b21 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/AlarmsViewModelTest.kt @@ -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 = (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) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/RingtonePickerTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/RingtonePickerTest.kt new file mode 100644 index 0000000..864209c --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ui/alarms/RingtonePickerTest.kt @@ -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().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()).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()).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()).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]) + } + } +}