diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt index 316e33d..5020eb5 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt @@ -21,6 +21,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.storageValue import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig +import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN import java.time.ZoneId import kotlinx.coroutines.CoroutineScope @@ -256,6 +257,18 @@ class SettingsPrefs @Inject constructor( store.edit { it[SHOW_WEEK_NUMBERS_KEY] = enabled } } + /** + * How the Month view lays itself out (#38, #53). Defaults to [MonthViewStyle.Paged] + * — the historical behaviour, so existing installs see no change until they opt in. + */ + val monthViewStyle: Flow = store.data.map { prefs -> + prefs[MONTH_VIEW_STYLE_KEY].toEnum(MonthViewStyle.Paged) + } + + suspend fun setMonthViewStyle(style: MonthViewStyle) { + store.edit { it[MONTH_VIEW_STYLE_KEY] = style.name } + } + /** * Where the jump-to-today control lives (issue #60). Default OFF — the * historical layout, where it's an extended FAB that fades in above the "+" @@ -775,6 +788,7 @@ class SettingsPrefs @Inject constructor( internal val PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display") internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events") internal val SHOW_WEEK_NUMBERS_KEY = booleanPreferencesKey("show_week_numbers") + internal val MONTH_VIEW_STYLE_KEY = stringPreferencesKey("month_view_style") internal val TODAY_BUTTON_IN_TOOLBAR_KEY = booleanPreferencesKey("today_button_in_toolbar") internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view") internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views") diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewStyle.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewStyle.kt new file mode 100644 index 0000000..6b636a4 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewStyle.kt @@ -0,0 +1,45 @@ +package de.jeanlucmakiola.calendula.ui.month + +import androidx.annotation.StringRes +import de.jeanlucmakiola.calendula.R + +/** + * How the Month view lays itself out (#38, #53). + * + * One setting rather than two independent toggles: "vertical scrolling" and + * "month plus agenda" would otherwise multiply into four combinations to build + * and test, most of which nobody asked for. + * + * [Split] does **not** replace or disable the Agenda view — that stays a forward + * multi-day window with its own range model, while the split pane lists a single + * selected day. + */ +enum class MonthViewStyle { + /** Month pages, swiped left/right. Full event bars and pills per day. */ + Paged, + + /** + * One uninterrupted vertical stream of weeks. Each date appears exactly once, + * which is the point — paging repeats a boundary week at both ends (#38). + */ + Continuous, + + /** Compact dots-only grid over a list of the selected day's events (#53). */ + Split, +} + +@get:StringRes +val MonthViewStyle.labelRes: Int + get() = when (this) { + MonthViewStyle.Paged -> R.string.month_style_paged + MonthViewStyle.Continuous -> R.string.month_style_continuous + MonthViewStyle.Split -> R.string.month_style_split + } + +@get:StringRes +val MonthViewStyle.descriptionRes: Int + get() = when (this) { + MonthViewStyle.Paged -> R.string.month_style_paged_summary + MonthViewStyle.Continuous -> R.string.month_style_continuous_summary + MonthViewStyle.Split -> R.string.month_style_split_summary + } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/MonthViewStylePicker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/MonthViewStylePicker.kt new file mode 100644 index 0000000..ffb7a8e --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/MonthViewStylePicker.kt @@ -0,0 +1,297 @@ +package de.jeanlucmakiola.calendula.ui.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle +import de.jeanlucmakiola.calendula.ui.month.descriptionRes +import de.jeanlucmakiola.calendula.ui.month.labelRes +import de.jeanlucmakiola.floret.components.FullScreenPicker + +/** + * The Month view style chooser (#38, #53). Each option carries a schematic of the + * layout it produces rather than a bare label — the three differ in *shape*, which + * a word like "Continuous" doesn't convey on its own. + * + * Tapping applies immediately and the picker stays open, matching the App name + * picker: the change is worth seeing land before leaving. + */ +@Composable +internal fun MonthViewStylePicker( + selected: MonthViewStyle, + onSelect: (MonthViewStyle) -> Unit, + onDismiss: () -> Unit, +) { + FullScreenPicker( + title = stringResource(R.string.settings_month_view_style), + onDismiss = onDismiss, + predictiveBack = true, + ) { + Text( + text = stringResource(R.string.settings_month_view_style_summary), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + ) + Spacer(Modifier.height(24.dp)) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .selectableGroup(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + MonthViewStyle.entries.forEach { style -> + MonthStyleOptionCard( + style = style, + selected = style == selected, + onClick = { onSelect(style) }, + ) + } + } + } +} + +/** + * One selectable style: its schematic, name and a line on what it does. Selection + * reads three ways over — border weight, container tint and a check — so it never + * rests on colour alone. + */ +@Composable +private fun MonthStyleOptionCard( + style: MonthViewStyle, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val shape = RoundedCornerShape(24.dp) + val borderColor = if (selected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outlineVariant + } + val containerColor = if (selected) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f) + } else { + MaterialTheme.colorScheme.surfaceContainerHigh + } + Row( + modifier = modifier + .fillMaxWidth() + .clip(shape) + .background(containerColor) + .border(width = if (selected) 2.dp else 1.dp, color = borderColor, shape = shape) + .selectable(selected = selected, role = Role.RadioButton, onClick = onClick) + .padding(all = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + MonthStyleSchematic(style) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(style.labelRes), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = stringResource(style.descriptionRes), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + // Selection indicator: a filled check when active, an empty ring otherwise. + Box( + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + .background(if (selected) MaterialTheme.colorScheme.primary else Color.Transparent) + .then( + if (selected) { + Modifier + } else { + Modifier.border(1.dp, MaterialTheme.colorScheme.outlineVariant, CircleShape) + }, + ), + contentAlignment = Alignment.Center, + ) { + if (selected) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(16.dp), + ) + } + } + } +} + +private val SCHEMATIC_WIDTH = 64.dp +private val SCHEMATIC_HEIGHT = 60.dp +private val SCHEMATIC_SHAPE = RoundedCornerShape(10.dp) + +/** + * A miniature of the layout, drawn from plain boxes on theme tokens — small + * enough to read as an icon, literal enough that the three are told apart at a + * glance: pages sit side by side, the continuous stream runs off both edges, the + * split style stacks a dotted grid over a list. + */ +@Composable +private fun MonthStyleSchematic(style: MonthViewStyle, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .size(width = SCHEMATIC_WIDTH, height = SCHEMATIC_HEIGHT) + .clip(SCHEMATIC_SHAPE) + .background(MaterialTheme.colorScheme.surfaceContainerLowest) + .clipToBounds(), + contentAlignment = Alignment.Center, + ) { + when (style) { + MonthViewStyle.Paged -> PagedSchematic() + MonthViewStyle.Continuous -> ContinuousSchematic() + MonthViewStyle.Split -> SplitSchematic() + } + } +} + +/** Two pages side by side, the second peeking in — a swipe away. */ +@Composable +private fun PagedSchematic() { + Row( + modifier = Modifier.fillMaxWidth().padding(start = 6.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + SchematicGrid(rows = 4, modifier = Modifier.weight(1f)) + // The next page, cropped by the container — the swipe affordance. + SchematicGrid(rows = 4, modifier = Modifier.width(20.dp), dim = true) + } +} + +/** One column of weeks running off both edges: no page breaks, no repeated days. */ +@Composable +private fun ContinuousSchematic() { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 6.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + // Six rows in a 60dp box overflow deliberately: the stream is clipped top + // and bottom rather than fitting neatly, which is the whole idea. + repeat(6) { index -> + SchematicWeekRow(dim = index == 0 || index == 5) + } + } +} + +/** A squished dotted grid over the selected day's list. */ +@Composable +private fun SplitSchematic() { + Column( + modifier = Modifier.fillMaxWidth().padding(6.dp), + verticalArrangement = Arrangement.spacedBy(5.dp), + ) { + SchematicGrid(rows = 3, dotted = true) + // The day pane: two stand-in event rows. + repeat(2) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(6.dp) + .clip(RoundedCornerShape(3.dp)) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.55f)), + ) + } + } +} + +/** [rows] week rows of seven cells; [dotted] shrinks the cells to event dots. */ +@Composable +private fun SchematicGrid( + rows: Int, + modifier: Modifier = Modifier, + dim: Boolean = false, + dotted: Boolean = false, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(if (dotted) 4.dp else 3.dp), + ) { + repeat(rows) { + if (dotted) SchematicDotRow(dim) else SchematicWeekRow(dim) + } + } +} + +/** A week as seven filled cells. */ +@Composable +private fun SchematicWeekRow(dim: Boolean = false, modifier: Modifier = Modifier) { + val color = MaterialTheme.colorScheme.onSurfaceVariant + .copy(alpha = if (dim) 0.18f else 0.38f) + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(2.dp), + ) { + repeat(7) { + Box( + modifier = Modifier + .weight(1f) + .height(5.dp) + .clip(RoundedCornerShape(1.5.dp)) + .background(color), + ) + } + } +} + +/** A week as seven event dots — the split style's compact grid. */ +@Composable +private fun SchematicDotRow(dim: Boolean = false, modifier: Modifier = Modifier) { + val color = MaterialTheme.colorScheme.onSurfaceVariant + .copy(alpha = if (dim) 0.18f else 0.38f) + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + repeat(7) { + Box( + modifier = Modifier + .weight(1f) + .height(3.dp) + .clip(CircleShape) + .background(color), + ) + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt index f58c743..6e983b9 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt @@ -125,6 +125,7 @@ import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.InlineTextField import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig +import de.jeanlucmakiola.calendula.ui.month.labelRes import de.jeanlucmakiola.floret.components.ReorderableColumn import de.jeanlucmakiola.floret.components.ReorderableRowHeight import de.jeanlucmakiola.calendula.ui.common.icon @@ -847,12 +848,24 @@ private fun ViewsScreen( viewModel: SettingsViewModel, onBack: () -> Unit, ) { + var showMonthStyle by remember { mutableStateOf(false) } + CollapsingScaffold( title = stringResource(R.string.settings_section_views), onBack = onBack, ) { val config = state.quickSwitchConfig + // Per-view layout, above the cross-view switcher/order settings below. + SectionHeader(stringResource(R.string.settings_month_header)) + GroupedRow( + title = stringResource(R.string.settings_month_view_style), + summary = stringResource(state.monthViewStyle.labelRes), + position = Position.Alone, + onClick = { showMonthStyle = true }, + ) + + Spacer(Modifier.height(24.dp)) SectionHeader(stringResource(R.string.settings_quick_switch_header)) SettingsHint(stringResource(R.string.settings_quick_switch_hint)) Spacer(Modifier.height(8.dp)) @@ -898,6 +911,14 @@ private fun ViewsScreen( ) } } + + if (showMonthStyle) { + MonthViewStylePicker( + selected = state.monthViewStyle, + onSelect = viewModel::setMonthViewStyle, + onDismiss = { showMonthStyle = false }, + ) + } } /** One reorderable view row: the view's icon and name, an optional [trailing] diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt index 4fbb436..8f59b2c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt @@ -13,6 +13,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig +import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle /** * Settings screen state (M4). Persisted preferences are instant to read, so @@ -51,6 +52,8 @@ data class SettingsUiState( val defaultView: CalendarView = CalendarView.Week, /** Which views the top-bar quick-switch button cycles through, and their order (#24). */ val quickSwitchConfig: QuickSwitchConfig = QuickSwitchConfig.Default, + /** How the Month view lays itself out: pages, continuous scroll, or split (#38, #53). */ + val monthViewStyle: MonthViewStyle = MonthViewStyle.Paged, /** Order of the views in the navigation drawer (#24); every view is always listed. */ val drawerViewOrder: List = IMPLEMENTED_VIEWS, /** Optional event-form fields shown by default (rest behind "more fields"). */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt index de3bcbe..a549089 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt @@ -35,6 +35,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.agenda.storageValue import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig +import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_PAST_DISPLAY_KEY @@ -150,8 +151,12 @@ class SettingsViewModel @Inject constructor( prefs.dimCompletedEvents, // View customisation (#24) folded into one flow so it fits this // group — the outer combine is already at its five-arg limit. - combine(prefs.quickSwitchConfig, prefs.drawerViewOrder) { quickSwitch, drawer -> - ViewCustomization(quickSwitch, drawer) + combine( + prefs.quickSwitchConfig, + prefs.drawerViewOrder, + prefs.monthViewStyle, + ) { quickSwitch, drawer, monthStyle -> + ViewCustomization(quickSwitch, drawer, monthStyle) }, ) { showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization -> MiscSettings(showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization) @@ -173,6 +178,7 @@ class SettingsViewModel @Inject constructor( dimCompletedEvents = misc.dimCompletedEvents, quickSwitchConfig = misc.viewCustomization.quickSwitch, drawerViewOrder = misc.viewCustomization.drawerOrder, + monthViewStyle = misc.viewCustomization.monthViewStyle, allowColorOnUnsupportedCalendars = defaults.allowColor, defaultReminderMinutes = defaults.defaultReminder, defaultAllDayReminderMinutes = defaults.allDayReminder, @@ -286,6 +292,7 @@ class SettingsViewModel @Inject constructor( private data class ViewCustomization( val quickSwitch: QuickSwitchConfig, val drawerOrder: List, + val monthViewStyle: MonthViewStyle, ) /** Contact special-dates sub-page (issue #15); its own flow — the main state is full. */ @@ -562,6 +569,10 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { prefs.updateQuickSwitch { it.copy(order = order) } } } + fun setMonthViewStyle(style: MonthViewStyle) { + viewModelScope.launch { prefs.setMonthViewStyle(style) } + } + fun setDrawerViewOrder(order: List) { viewModelScope.launch { prefs.setDrawerViewOrder(order) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c7ff4e1..988f6c7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -372,6 +372,17 @@ %d days Views + + Month view + Month view style + Choose how the month view is laid out and how you move through it. + Pages + One month at a time. Swipe sideways to change month. + Continuous + Scroll up and down through the weeks. No month is cut off and no day appears twice. + Split + A compact grid with dots for events, and the day you tap listed underneath. + Nothing scheduled Quick-switch button Choose which views the top-right button cycles through, and drag to reorder them. Turned-off views stay reachable from the navigation menu. Navigation menu @@ -411,7 +422,7 @@ Add or improve a language on Weblate Theme, default view, week start - Quick-switch button and menu order + Month layout, quick-switch button, menu order Default fields for new events Event reminders Contact birthdays & anniversaries diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt index 5b53234..7693801 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt @@ -13,6 +13,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig +import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import kotlinx.datetime.DayOfWeek @@ -125,6 +126,30 @@ class SettingsPrefsTest { assertThat(prefs.agendaWidgetRange.first()).isEqualTo(AgendaRange.Week) } + @Test + fun `month view style defaults to paged and round-trips`(@TempDir tempDir: Path) = runTest { + val prefs = SettingsPrefs(newDataStore(tempDir)) + assertThat(prefs.monthViewStyle.first()).isEqualTo(MonthViewStyle.Paged) + + prefs.setMonthViewStyle(MonthViewStyle.Split) + assertThat(prefs.monthViewStyle.first()).isEqualTo(MonthViewStyle.Split) + + prefs.setMonthViewStyle(MonthViewStyle.Continuous) + assertThat(prefs.monthViewStyle.first()).isEqualTo(MonthViewStyle.Continuous) + } + + @Test + fun `garbage stored month view style falls back to paged`(@TempDir tempDir: Path) = runTest { + val store = newDataStore(tempDir) + val prefs = SettingsPrefs(store) + store.updateData { p -> + val m = p.toMutablePreferences() + m[SettingsPrefs.MONTH_VIEW_STYLE_KEY] = "Carousel" + m + } + assertThat(prefs.monthViewStyle.first()).isEqualTo(MonthViewStyle.Paged) + } + @Test fun `garbage stored enum falls back to default`(@TempDir tempDir: Path) = runTest { val store = newDataStore(tempDir)