feat(views): customizable quick-switch cycle + drawer order (#24)

Add a "Views" settings section that lets users pick which views the
top-bar quick-switch button cycles through and drag to reorder them,
plus an independent drag-to-reorder for the navigation drawer's view
list. Two separate configs: a view turned off in the quick-switch
cycle is still reachable from the drawer, which always lists every view.

- QuickSwitchConfig (order + enabled set) and a drawer order persisted
  in SettingsPrefs (comma-joined enum names; "!" marks a disabled view).
  Missing views append enabled and unknown names drop, so a future view
  defaults into both lists.
- The pill cycles through the configured enabled views in order; the
  drawer renders CalendarDrawer from the drawer order. Both threaded
  from CalendarHost via CalendarHostViewModel.
- New ReorderableColumn: dependency-free, measurement-driven drag
  reordering for the short grouped-card settings lists (no LazyColumn,
  since settings are a single verticalScroll column). Commits one write
  per gesture.
- The switch needs two targets, so the last two enabled views can't be
  turned off.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 12:19:50 +02:00
parent 69460bc35a
commit b9e800e9fc
16 changed files with 539 additions and 10 deletions

View File

@@ -12,6 +12,8 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.storageValue import de.jeanlucmakiola.calendula.ui.agenda.storageValue
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
@@ -202,6 +204,34 @@ class SettingsPrefs @Inject constructor(
store.edit { it[DEFAULT_VIEW_KEY] = view.name } store.edit { it[DEFAULT_VIEW_KEY] = view.name }
} }
/**
* Quick-switch button customisation (#24): the ordered full view list plus
* which views are enabled in the cycle. Stored comma-joined by enum name, a
* "!" prefix marking a disabled view (e.g. "Month,Week,!Day,Agenda"). Missing
* views are appended enabled and unknown names dropped, so a future view
* defaults into the cycle. An absent key means [QuickSwitchConfig.Default].
*/
val quickSwitchConfig: Flow<QuickSwitchConfig> = store.data.map { prefs ->
parseQuickSwitch(prefs[QUICK_SWITCH_VIEWS_KEY])
}
suspend fun setQuickSwitchConfig(config: QuickSwitchConfig) {
store.edit { it[QUICK_SWITCH_VIEWS_KEY] = serializeQuickSwitch(config) }
}
/**
* Navigation-drawer view order (#24). Comma-joined enum names; missing views
* are appended in default order and unknown names dropped. Absent key means
* [IMPLEMENTED_VIEWS] (the historical fixed order).
*/
val drawerViewOrder: Flow<List<CalendarView>> = store.data.map { prefs ->
parseViewOrder(prefs[DRAWER_VIEW_ORDER_KEY])
}
suspend fun setDrawerViewOrder(order: List<CalendarView>) {
store.edit { it[DRAWER_VIEW_ORDER_KEY] = order.joinToString(",") { view -> view.name } }
}
/** /**
* Optional event-form fields shown by default (the rest hide behind * Optional event-form fields shown by default (the rest hide behind
* "more fields"). Stored comma-joined by enum name: an absent key means * "more fields"). Stored comma-joined by enum name: an absent key means
@@ -433,6 +463,40 @@ class SettingsPrefs @Inject constructor(
.toSet() .toSet()
} }
/** Parse a plain comma-joined view order, completed to every implemented view. */
private fun parseViewOrder(stored: String?): List<CalendarView> =
completeViewOrder(
stored?.split(',').orEmpty()
.mapNotNull { name -> CalendarView.entries.firstOrNull { it.name == name.trim() } },
)
/** Parse the quick-switch config; "!"-prefixed names are disabled. */
private fun parseQuickSwitch(stored: String?): QuickSwitchConfig {
if (stored == null) return QuickSwitchConfig.Default
val parsed = stored.split(',').mapNotNull { raw ->
val token = raw.trim()
val disabled = token.startsWith("!")
val name = if (disabled) token.drop(1) else token
CalendarView.entries.firstOrNull { it.name == name }?.let { it to disabled }
}
val order = completeViewOrder(parsed.map { it.first })
// Only views explicitly stored disabled are excluded; anything appended
// (a view added in a later release) defaults into the cycle.
val disabled = parsed.filter { it.second }.map { it.first }.toSet()
return QuickSwitchConfig(order, order.filterNot { it in disabled }.toSet())
}
private fun serializeQuickSwitch(config: QuickSwitchConfig): String =
completeViewOrder(config.order).joinToString(",") { view ->
if (view in config.enabled) view.name else "!${view.name}"
}
/** Keep the given order (de-duplicated), then append any views it omits. */
private fun completeViewOrder(seen: List<CalendarView>): List<CalendarView> {
val ordered = seen.distinct()
return ordered + IMPLEMENTED_VIEWS.filterNot { it in ordered }
}
companion object { companion object {
internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode") internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode")
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color") internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
@@ -445,6 +509,8 @@ class SettingsPrefs @Inject constructor(
internal val PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display") internal val PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display")
internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events") internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events")
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view") internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views")
internal val DRAWER_VIEW_ORDER_KEY = stringPreferencesKey("drawer_view_order")
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields") internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
internal val AUTOFOCUS_EVENT_TITLE_KEY = booleanPreferencesKey("autofocus_event_title") internal val AUTOFOCUS_EVENT_TITLE_KEY = booleanPreferencesKey("autofocus_event_title")
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled") internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")

View File

@@ -79,6 +79,11 @@ fun CalendarHost(
// correcting it. Brief blank first frame, matching the onboarding gate above. // correcting it. Brief blank first frame, matching the onboarding gate above.
val defaultView = viewModel.defaultView.collectAsStateWithLifecycle().value ?: return val defaultView = viewModel.defaultView.collectAsStateWithLifecycle().value ?: return
// View customisation (#24): the quick-switch cycle and drawer order. Both have
// sensible non-empty initial values, so they're ready before the first frame.
val quickSwitchViews = viewModel.quickSwitchViews.collectAsStateWithLifecycle().value
val drawerViewOrder = viewModel.drawerViewOrder.collectAsStateWithLifecycle().value
var viewStack by rememberSaveable(stateSaver = viewStackSaver) { var viewStack by rememberSaveable(stateSaver = viewStackSaver) {
mutableStateOf(listOf(defaultView)) mutableStateOf(listOf(defaultView))
} }
@@ -260,6 +265,8 @@ fun CalendarHost(
onOpenSettings = onOpenSettings, onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch, onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent, onCreateEvent = onCreateEvent,
quickSwitchViews = quickSwitchViews,
drawerViewOrder = drawerViewOrder,
) )
CalendarView.Day -> DayScreen( CalendarView.Day -> DayScreen(
selectedView = currentView, selectedView = currentView,
@@ -269,6 +276,8 @@ fun CalendarHost(
onOpenSearch = onOpenSearch, onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent, onCreateEvent = onCreateEvent,
initialDateIso = pendingDayIso, initialDateIso = pendingDayIso,
quickSwitchViews = quickSwitchViews,
drawerViewOrder = drawerViewOrder,
) )
CalendarView.Month -> MonthScreen( CalendarView.Month -> MonthScreen(
selectedView = currentView, selectedView = currentView,
@@ -277,6 +286,8 @@ fun CalendarHost(
onOpenSettings = onOpenSettings, onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch, onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent, onCreateEvent = onCreateEvent,
quickSwitchViews = quickSwitchViews,
drawerViewOrder = drawerViewOrder,
) )
CalendarView.Agenda -> AgendaScreen( CalendarView.Agenda -> AgendaScreen(
selectedView = currentView, selectedView = currentView,
@@ -285,6 +296,8 @@ fun CalendarHost(
onOpenSettings = onOpenSettings, onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch, onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent, onCreateEvent = onCreateEvent,
quickSwitchViews = quickSwitchViews,
drawerViewOrder = drawerViewOrder,
) )
} }
} }

View File

@@ -5,8 +5,10 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject import javax.inject.Inject
@@ -26,4 +28,21 @@ class CalendarHostViewModel @Inject constructor(
started = SharingStarted.WhileSubscribed(5_000L), started = SharingStarted.WhileSubscribed(5_000L),
initialValue = null, initialValue = null,
) )
/** Views the top-bar quick-switch pill cycles through, in the user's order (#24). */
val quickSwitchViews: StateFlow<List<CalendarView>> = prefs.quickSwitchConfig
.map { it.cycle }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = IMPLEMENTED_VIEWS,
)
/** Order of the views in the navigation drawer (#24); every view always shown. */
val drawerViewOrder: StateFlow<List<CalendarView>> = prefs.drawerViewOrder
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = IMPLEMENTED_VIEWS,
)
} }

View File

@@ -64,6 +64,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.GroupedRow import de.jeanlucmakiola.calendula.ui.common.GroupedRow
import de.jeanlucmakiola.calendula.ui.common.Position import de.jeanlucmakiola.calendula.ui.common.Position
@@ -96,6 +97,8 @@ fun AgendaScreen(
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
onOpenSearch: () -> Unit, onOpenSearch: () -> Unit,
onCreateEvent: (LocalDate, Int?) -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit,
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: AgendaViewModel = hiltViewModel(), viewModel: AgendaViewModel = hiltViewModel(),
) { ) {
@@ -120,6 +123,7 @@ fun AgendaScreen(
CalendarDrawer( CalendarDrawer(
currentView = selectedView, currentView = selectedView,
currentDate = anchor, currentDate = anchor,
viewOrder = drawerViewOrder,
onSelectView = { view -> onSelectView = { view ->
onSelectView(view) onSelectView(view)
scope.launch { drawerState.close() } scope.launch { drawerState.close() }
@@ -140,7 +144,7 @@ fun AgendaScreen(
topBar = { topBar = {
AgendaTopBar( AgendaTopBar(
selectedView = selectedView, selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next()) }, onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
onOpenDrawer = { scope.launch { drawerState.open() } }, onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch, onOpenSearch = onOpenSearch,
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,

View File

@@ -59,6 +59,7 @@ fun CalendarDrawer(
onSelectView: (CalendarView) -> Unit, onSelectView: (CalendarView) -> Unit,
onJumpToDate: (LocalDate) -> Unit, onJumpToDate: (LocalDate) -> Unit,
onSettings: () -> Unit, onSettings: () -> Unit,
viewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
) { ) {
var showDatePicker by remember { mutableStateOf(false) } var showDatePicker by remember { mutableStateOf(false) }
@@ -73,10 +74,10 @@ fun CalendarDrawer(
DrawerHeader() DrawerHeader()
DrawerSectionHeader(stringResource(R.string.view_section)) DrawerSectionHeader(stringResource(R.string.view_section))
IMPLEMENTED_VIEWS.forEachIndexed { index, view -> viewOrder.forEachIndexed { index, view ->
GroupedRow( GroupedRow(
title = stringResource(view.labelRes), title = stringResource(view.labelRes),
position = positionOf(index, IMPLEMENTED_VIEWS.size), position = positionOf(index, viewOrder.size),
selected = view == currentView, selected = view == currentView,
minHeight = 56.dp, minHeight = 56.dp,
leading = { Icon(view.icon, contentDescription = null) }, leading = { Icon(view.icon, contentDescription = null) },

View File

@@ -45,11 +45,39 @@ val IMPLEMENTED_VIEWS: List<CalendarView> =
/** Next view in [available], wrapping around. Falls back to Month if absent. */ /** Next view in [available], wrapping around. Falls back to Month if absent. */
fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): CalendarView { fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): CalendarView {
if (available.isEmpty()) return this
val i = available.indexOf(this) val i = available.indexOf(this)
if (i < 0) return available.firstOrNull() ?: CalendarView.Month if (i < 0) return available.first()
return available[(i + 1) % available.size] return available[(i + 1) % available.size]
} }
/**
* The user's customisation of the top-bar quick-switch button (#24): which views
* it cycles through ([enabled]) and in what [order]. [order] always lists every
* implemented view — the settings screen reorders the whole set — while [cycle]
* is the subset the pill actually steps through, in [order]. The navigation
* drawer keeps its own separate order and always lists every view, so a view
* disabled here stays reachable there.
*/
data class QuickSwitchConfig(
val order: List<CalendarView>,
val enabled: Set<CalendarView>,
) {
/** Views the pill steps through, in [order]. */
val cycle: List<CalendarView> get() = order.filter { it in enabled }
companion object {
/** All views, in default order, all enabled. */
val Default = QuickSwitchConfig(IMPLEMENTED_VIEWS, IMPLEMENTED_VIEWS.toSet())
/**
* Fewest views that keep the switch meaningful — a "switch" needs at
* least two targets, so the settings screen blocks disabling below this.
*/
const val MIN_ENABLED = 2
}
}
/** /**
* The top-level view back stack (bottom → top): the [default] home view always * The top-level view back stack (bottom → top): the [default] home view always
* sits at the bottom. Pressing back pops one level until only the home view * sits at the bottom. Pressing back pops one level until only the home view

View File

@@ -0,0 +1,103 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.zIndex
/**
* A vertical list whose rows can be dragged into a new order by their handle.
*
* Built for the short, fixed grouped-card lists in Settings (#24) — no external
* dependency and no [androidx.compose.foundation.lazy.LazyColumn] (the settings
* screens are a single [androidx.compose.foundation.verticalScroll] column, which
* can't nest a scrolling list). [rowContent] receives the [Position] for the
* row's current place in the order (so it can reuse [GroupedRow]'s card shaping)
* and a `dragHandle` [Modifier] to attach to whatever element should start a
* drag. While a row is held it floats above the others, which reflow live; the
* final order is committed once via [onReorder] on release, so the persistence
* layer sees one write per gesture, not one per crossed row.
*
* Reordering is measurement-driven: each row reports its own height, so rows of
* unequal height still swap at the right point.
*/
@Composable
fun <T> ReorderableColumn(
items: List<T>,
keyOf: (T) -> Any,
onReorder: (List<T>) -> Unit,
modifier: Modifier = Modifier,
rowContent: @Composable (item: T, position: Position, dragHandle: Modifier, isDragging: Boolean) -> Unit,
) {
// Local working copy so rows reflow instantly during a drag; re-seeded when
// the incoming list changes (including the echo of our own committed order).
var order by remember(items) { mutableStateOf(items) }
var draggedKey by remember { mutableStateOf<Any?>(null) }
// Translation of the held row from its settled slot; a swap folds one slot
// height back into this so the row stays under the finger across the swap.
var dragOffsetY by remember { mutableStateOf(0f) }
val heights = remember { mutableStateMapOf<Any, Int>() }
Column(modifier) {
order.forEachIndexed { index, item ->
val key = keyOf(item)
val isDragging = key == draggedKey
val dragHandle = Modifier.pointerInput(key) {
detectDragGestures(
onDragStart = {
draggedKey = key
dragOffsetY = 0f
},
onDragEnd = {
draggedKey = null
dragOffsetY = 0f
onReorder(order)
},
onDragCancel = {
draggedKey = null
dragOffsetY = 0f
},
onDrag = { change, dragAmount ->
change.consume()
dragOffsetY += dragAmount.y
val cur = order.indexOfFirst { keyOf(it) == key }
if (cur < 0) return@detectDragGestures
// Swap once the row has travelled past half of the
// neighbour it's heading toward, in the drag direction.
if (dragAmount.y > 0 && cur < order.lastIndex) {
val nextHeight = heights[keyOf(order[cur + 1])] ?: 0
if (nextHeight > 0 && dragOffsetY > nextHeight / 2f) {
order = order.toMutableList().apply { add(cur + 1, removeAt(cur)) }
dragOffsetY -= nextHeight
}
} else if (dragAmount.y < 0 && cur > 0) {
val prevHeight = heights[keyOf(order[cur - 1])] ?: 0
if (prevHeight > 0 && dragOffsetY < -prevHeight / 2f) {
order = order.toMutableList().apply { add(cur - 1, removeAt(cur)) }
dragOffsetY += prevHeight
}
}
},
)
}
Box(
Modifier
.onSizeChanged { heights[key] = it.height }
.zIndex(if (isDragging) 1f else 0f)
.graphicsLayer { translationY = if (isDragging) dragOffsetY else 0f },
) {
rowContent(item, positionOf(index, order.size), dragHandle, isDragging)
}
}
}
}

View File

@@ -72,6 +72,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.NowLine import de.jeanlucmakiola.calendula.ui.common.NowLine
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
@@ -116,6 +117,8 @@ fun DayScreen(
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
onOpenSearch: () -> Unit, onOpenSearch: () -> Unit,
onCreateEvent: (LocalDate, Int?) -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit,
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
initialDateIso: String? = null, initialDateIso: String? = null,
viewModel: DayViewModel = hiltViewModel(), viewModel: DayViewModel = hiltViewModel(),
@@ -175,6 +178,7 @@ fun DayScreen(
CalendarDrawer( CalendarDrawer(
currentView = selectedView, currentView = selectedView,
currentDate = date, currentDate = date,
viewOrder = drawerViewOrder,
onSelectView = { view -> onSelectView = { view ->
onSelectView(view) onSelectView(view)
scope.launch { drawerState.close() } scope.launch { drawerState.close() }
@@ -196,7 +200,7 @@ fun DayScreen(
DayTopBar( DayTopBar(
date = date, date = date,
selectedView = selectedView, selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next()) }, onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
onOpenDrawer = { scope.launch { drawerState.open() } }, onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch, onOpenSearch = onOpenSearch,
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,

View File

@@ -68,6 +68,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
@@ -98,6 +99,8 @@ fun MonthScreen(
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
onOpenSearch: () -> Unit, onOpenSearch: () -> Unit,
onCreateEvent: (LocalDate, Int?) -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit,
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: MonthViewModel = hiltViewModel(), viewModel: MonthViewModel = hiltViewModel(),
) { ) {
@@ -157,6 +160,7 @@ fun MonthScreen(
CalendarDrawer( CalendarDrawer(
currentView = selectedView, currentView = selectedView,
currentDate = LocalDate(month.year, month.month, 1), currentDate = LocalDate(month.year, month.month, 1),
viewOrder = drawerViewOrder,
onSelectView = { view -> onSelectView = { view ->
onSelectView(view) onSelectView(view)
scope.launch { drawerState.close() } scope.launch { drawerState.close() }
@@ -178,7 +182,7 @@ fun MonthScreen(
MonthTopBar( MonthTopBar(
month = month, month = month,
selectedView = selectedView, selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next()) }, onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
onOpenDrawer = { scope.launch { drawerState.open() } }, onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch, onOpenSearch = onOpenSearch,
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,

View File

@@ -41,6 +41,8 @@ import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Dashboard import androidx.compose.material.icons.filled.Dashboard
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.DragHandle
import androidx.compose.material.icons.filled.SwapVert
import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.Favorite
@@ -99,9 +101,13 @@ import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
import de.jeanlucmakiola.calendula.ui.common.GroupedRow import de.jeanlucmakiola.calendula.ui.common.GroupedRow
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.common.ReorderableColumn
import de.jeanlucmakiola.calendula.ui.common.icon
import de.jeanlucmakiola.calendula.ui.common.labelRes import de.jeanlucmakiola.calendula.ui.common.labelRes
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.OptionPicker import de.jeanlucmakiola.calendula.ui.common.OptionPicker
@@ -121,7 +127,7 @@ import java.time.format.TextStyle as JavaTextStyle
import java.util.Calendar import java.util.Calendar
/** The settings sub-screens reached from the hub's category rows. */ /** The settings sub-screens reached from the hub's category rows. */
private enum class SettingsSection { Appearance, EventForm, Notifications } private enum class SettingsSection { Appearance, Views, EventForm, Notifications }
/** /**
* Token-based accent for a leading icon chip (container / on-container pair). * Token-based accent for a leading icon chip (container / on-container pair).
@@ -166,6 +172,13 @@ fun SettingsScreen(
) { ) {
AppearanceScreen(state = state, viewModel = viewModel, onBack = { section = null }) AppearanceScreen(state = state, viewModel = viewModel, onBack = { section = null })
} }
AnimatedVisibility(
visible = section == SettingsSection.Views,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
) {
ViewsScreen(state = state, viewModel = viewModel, onBack = { section = null })
}
AnimatedVisibility( AnimatedVisibility(
visible = section == SettingsSection.EventForm, visible = section == SettingsSection.EventForm,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(), enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
@@ -204,6 +217,13 @@ private fun SettingsHub(
leading = { CategoryIcon(Icons.Default.Palette, ChipAccent.Neutral) }, leading = { CategoryIcon(Icons.Default.Palette, ChipAccent.Neutral) },
onClick = { onOpenSection(SettingsSection.Appearance) }, onClick = { onOpenSection(SettingsSection.Appearance) },
) )
GroupedRow(
title = stringResource(R.string.settings_section_views),
summary = stringResource(R.string.settings_views_subtitle),
position = Position.Middle,
leading = { CategoryIcon(Icons.Default.SwapVert, ChipAccent.Neutral) },
onClick = { onOpenSection(SettingsSection.Views) },
)
GroupedRow( GroupedRow(
title = stringResource(R.string.settings_section_event_form), title = stringResource(R.string.settings_section_event_form),
summary = stringResource(R.string.settings_event_form_subtitle), summary = stringResource(R.string.settings_event_form_subtitle),
@@ -664,6 +684,129 @@ private fun AppearanceScreen(
} }
} }
/**
* Views (#24): reorder the top-bar quick-switch cycle and choose which views it
* steps through, plus reorder the navigation-drawer list. Two independent lists —
* a view disabled in the quick-switch cycle is still reachable from the drawer,
* which always lists every view. The switch needs at least two targets, so the
* last [QuickSwitchConfig.MIN_ENABLED] enabled views can't be turned off.
*/
@Composable
private fun ViewsScreen(
state: SettingsUiState,
viewModel: SettingsViewModel,
onBack: () -> Unit,
) {
CollapsingScaffold(
title = stringResource(R.string.settings_section_views),
onBack = onBack,
) {
val config = state.quickSwitchConfig
SectionHeader(stringResource(R.string.settings_quick_switch_header))
SettingsHint(stringResource(R.string.settings_quick_switch_hint))
Spacer(Modifier.height(8.dp))
// Turning a view off is blocked once only the minimum remain enabled.
val canDisable = config.enabled.size > QuickSwitchConfig.MIN_ENABLED
ReorderableColumn(
items = config.order,
keyOf = { it },
onReorder = { viewModel.setQuickSwitchConfig(config.copy(order = it)) },
) { view, position, dragHandle, isDragging ->
val checked = view in config.enabled
ViewRow(
view = view,
position = position,
isDragging = isDragging,
dragHandle = dragHandle,
dimmed = !checked,
trailing = {
Switch(
checked = checked,
// Keep the last two on: with fewer, the pill can't switch.
enabled = !checked || canDisable,
onCheckedChange = { on ->
val enabled = if (on) config.enabled + view else config.enabled - view
viewModel.setQuickSwitchConfig(config.copy(enabled = enabled))
},
)
},
)
}
Spacer(Modifier.height(24.dp))
SectionHeader(stringResource(R.string.settings_drawer_order_header))
SettingsHint(stringResource(R.string.settings_drawer_order_hint))
Spacer(Modifier.height(8.dp))
ReorderableColumn(
items = state.drawerViewOrder,
keyOf = { it },
onReorder = { viewModel.setDrawerViewOrder(it) },
) { view, position, dragHandle, isDragging ->
ViewRow(
view = view,
position = position,
isDragging = isDragging,
dragHandle = dragHandle,
)
}
}
}
/** One reorderable view row: the view's icon and name, an optional [trailing]
* control, and a drag handle carrying the [dragHandle] gesture modifier. */
@Composable
private fun ViewRow(
view: CalendarView,
position: Position,
isDragging: Boolean,
dragHandle: Modifier,
dimmed: Boolean = false,
trailing: @Composable (() -> Unit)? = null,
) {
GroupedRow(
title = stringResource(view.labelRes),
position = position,
dimmed = dimmed,
minHeight = 64.dp,
container = if (isDragging) MaterialTheme.colorScheme.secondaryContainer else null,
leading = {
Icon(
imageVector = view.icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
trailing = {
Row(verticalAlignment = Alignment.CenterVertically) {
trailing?.invoke()
if (trailing != null) Spacer(Modifier.width(8.dp))
Box(
modifier = dragHandle.size(48.dp),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Default.DragHandle,
contentDescription = stringResource(R.string.reorder_drag_handle),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
},
)
}
/** Muted supporting text under a [SectionHeader], matching the form-fields hint. */
@Composable
private fun SettingsHint(text: String) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
}
@Composable @Composable
private fun EventFormScreen( private fun EventFormScreen(
state: SettingsUiState, state: SettingsUiState,

View File

@@ -9,6 +9,8 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventFormField import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
/** /**
* Settings screen state (M4). Persisted preferences are instant to read, so * Settings screen state (M4). Persisted preferences are instant to read, so
@@ -37,6 +39,10 @@ data class SettingsUiState(
val agendaShowRangeBar: Boolean = true, val agendaShowRangeBar: Boolean = true,
/** The calendar view the app opens on, and the home of the view back stack (M1). */ /** The calendar view the app opens on, and the home of the view back stack (M1). */
val defaultView: CalendarView = CalendarView.Week, val defaultView: CalendarView = CalendarView.Week,
/** Which views the top-bar quick-switch button cycles through, and their order (#24). */
val quickSwitchConfig: QuickSwitchConfig = QuickSwitchConfig.Default,
/** Order of the views in the navigation drawer (#24); every view is always listed. */
val drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
/** Optional event-form fields shown by default (rest behind "more fields"). */ /** Optional event-form fields shown by default (rest behind "more fields"). */
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS, val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
/** Whether the new-event form auto-focuses the title and shows the keyboard (#10). */ /** Whether the new-event form auto-focuses the title and shows the keyboard (#10). */

View File

@@ -21,6 +21,7 @@ import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.storageValue import de.jeanlucmakiola.calendula.ui.agenda.storageValue
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_PAST_DISPLAY_KEY import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_PAST_DISPLAY_KEY
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_RANGE_KEY import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_RANGE_KEY
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
@@ -103,8 +104,14 @@ class SettingsViewModel @Inject constructor(
prefs.autofocusEventTitle, prefs.autofocusEventTitle,
prefs.pastEventDisplay, prefs.pastEventDisplay,
prefs.dimCompletedEvents, prefs.dimCompletedEvents,
::MiscSettings, // 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)
},
) { showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization ->
MiscSettings(showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization)
},
) { base, defaults, overrides, views, misc -> ) { base, defaults, overrides, views, misc ->
base.copy( base.copy(
defaultView = views.defaultView, defaultView = views.defaultView,
@@ -116,6 +123,8 @@ class SettingsViewModel @Inject constructor(
autofocusEventTitle = misc.autofocusEventTitle, autofocusEventTitle = misc.autofocusEventTitle,
pastEventDisplay = misc.pastEventDisplay, pastEventDisplay = misc.pastEventDisplay,
dimCompletedEvents = misc.dimCompletedEvents, dimCompletedEvents = misc.dimCompletedEvents,
quickSwitchConfig = misc.viewCustomization.quickSwitch,
drawerViewOrder = misc.viewCustomization.drawerOrder,
allowColorOnUnsupportedCalendars = defaults.allowColor, allowColorOnUnsupportedCalendars = defaults.allowColor,
defaultReminderMinutes = defaults.defaultReminder, defaultReminderMinutes = defaults.defaultReminder,
defaultAllDayReminderMinutes = defaults.allDayReminder, defaultAllDayReminderMinutes = defaults.allDayReminder,
@@ -164,6 +173,12 @@ class SettingsViewModel @Inject constructor(
val autofocusEventTitle: Boolean, val autofocusEventTitle: Boolean,
val pastEventDisplay: PastEventDisplay, val pastEventDisplay: PastEventDisplay,
val dimCompletedEvents: Boolean, val dimCompletedEvents: Boolean,
val viewCustomization: ViewCustomization,
)
private data class ViewCustomization(
val quickSwitch: QuickSwitchConfig,
val drawerOrder: List<CalendarView>,
) )
fun setThemeMode(mode: ThemeMode) { fun setThemeMode(mode: ThemeMode) {
@@ -247,6 +262,14 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { prefs.setFormFieldDefault(field, enabled) } viewModelScope.launch { prefs.setFormFieldDefault(field, enabled) }
} }
fun setQuickSwitchConfig(config: QuickSwitchConfig) {
viewModelScope.launch { prefs.setQuickSwitchConfig(config) }
}
fun setDrawerViewOrder(order: List<CalendarView>) {
viewModelScope.launch { prefs.setDrawerViewOrder(order) }
}
fun setRemindersEnabled(enabled: Boolean) { fun setRemindersEnabled(enabled: Boolean) {
viewModelScope.launch { prefs.setRemindersEnabled(enabled) } viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
} }

View File

@@ -81,6 +81,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
import de.jeanlucmakiola.calendula.ui.common.NowLine import de.jeanlucmakiola.calendula.ui.common.NowLine
@@ -131,6 +132,8 @@ fun WeekScreen(
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
onOpenSearch: () -> Unit, onOpenSearch: () -> Unit,
onCreateEvent: (LocalDate, Int?) -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit,
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: WeekViewModel = hiltViewModel(), viewModel: WeekViewModel = hiltViewModel(),
) { ) {
@@ -195,6 +198,7 @@ fun WeekScreen(
CalendarDrawer( CalendarDrawer(
currentView = selectedView, currentView = selectedView,
currentDate = weekStart, currentDate = weekStart,
viewOrder = drawerViewOrder,
onSelectView = { view -> onSelectView = { view ->
onSelectView(view) onSelectView(view)
scope.launch { drawerState.close() } scope.launch { drawerState.close() }
@@ -216,7 +220,7 @@ fun WeekScreen(
WeekTopBar( WeekTopBar(
weekStart = weekStart, weekStart = weekStart,
selectedView = selectedView, selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next()) }, onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
onOpenDrawer = { scope.launch { drawerState.open() } }, onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch, onOpenSearch = onOpenSearch,
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,

View File

@@ -308,6 +308,12 @@
<item quantity="one">%d day</item> <item quantity="one">%d day</item>
<item quantity="other">%d days</item> <item quantity="other">%d days</item>
</plurals> </plurals>
<string name="settings_section_views">Views</string>
<string name="settings_quick_switch_header">Quick-switch button</string>
<string name="settings_quick_switch_hint">Choose which views the top-right button cycles through, and drag to reorder them. Turned-off views stay reachable from the navigation menu.</string>
<string name="settings_drawer_order_header">Navigation menu</string>
<string name="settings_drawer_order_hint">Drag to reorder the views listed in the navigation menu.</string>
<string name="reorder_drag_handle">Drag to reorder</string>
<string name="settings_section_event_form">New event form</string> <string name="settings_section_event_form">New event form</string>
<string name="settings_form_fields_hint">Fields shown by default — everything else sits behind \"More fields\"</string> <string name="settings_form_fields_hint">Fields shown by default — everything else sits behind \"More fields\"</string>
<string name="settings_autofocus_title">Focus title on new event</string> <string name="settings_autofocus_title">Focus title on new event</string>
@@ -343,6 +349,7 @@
<string name="settings_translate_hint">Add or improve a language on Weblate</string> <string name="settings_translate_hint">Add or improve a language on Weblate</string>
<!-- Hub category subtitles --> <!-- Hub category subtitles -->
<string name="settings_appearance_subtitle">Theme, default view, week start</string> <string name="settings_appearance_subtitle">Theme, default view, week start</string>
<string name="settings_views_subtitle">Quick-switch button and menu order</string>
<string name="settings_event_form_subtitle">Default fields for new events</string> <string name="settings_event_form_subtitle">Default fields for new events</string>
<string name="settings_notifications_subtitle">Event reminders</string> <string name="settings_notifications_subtitle">Event reminders</string>
<string name="settings_section_about">About</string> <string name="settings_section_about">About</string>

View File

@@ -6,6 +6,9 @@ import androidx.datastore.preferences.core.Preferences
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventFormField import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange 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 kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
@@ -149,6 +152,78 @@ class SettingsPrefsTest {
assertThat(prefs.defaultFormFields.first()).isEmpty() assertThat(prefs.defaultFormFields.first()).isEmpty()
} }
@Test
fun `quick-switch defaults to every view enabled in default order`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
val config = prefs.quickSwitchConfig.first()
assertThat(config.order).containsExactlyElementsIn(IMPLEMENTED_VIEWS).inOrder()
assertThat(config.enabled).containsExactlyElementsIn(IMPLEMENTED_VIEWS)
assertThat(config.cycle).containsExactlyElementsIn(IMPLEMENTED_VIEWS).inOrder()
}
@Test
fun `quick-switch config round-trips order and disabled views`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
val config = QuickSwitchConfig(
order = listOf(CalendarView.Agenda, CalendarView.Month, CalendarView.Week, CalendarView.Day),
enabled = setOf(CalendarView.Agenda, CalendarView.Month),
)
prefs.setQuickSwitchConfig(config)
val loaded = prefs.quickSwitchConfig.first()
assertThat(loaded.order).containsExactly(
CalendarView.Agenda, CalendarView.Month, CalendarView.Week, CalendarView.Day,
).inOrder()
assertThat(loaded.enabled).containsExactly(CalendarView.Agenda, CalendarView.Month)
assertThat(loaded.cycle).containsExactly(CalendarView.Agenda, CalendarView.Month).inOrder()
}
@Test
fun `quick-switch parse appends missing views enabled and drops unknowns`(@TempDir tempDir: Path) = runTest {
val store = newDataStore(tempDir)
val prefs = SettingsPrefs(store)
// Only Day (disabled) and Week stored, plus a bogus name; Month & Agenda absent.
store.updateData { p ->
val m = p.toMutablePreferences()
m[SettingsPrefs.QUICK_SWITCH_VIEWS_KEY] = "!Day,Week,Hologram"
m
}
val config = prefs.quickSwitchConfig.first()
// Stored order first (Day, Week), then the omitted views in default order.
assertThat(config.order).containsExactly(
CalendarView.Day, CalendarView.Week, CalendarView.Month, CalendarView.Agenda,
).inOrder()
// Day was explicitly disabled; the appended Month & Agenda default enabled.
assertThat(config.enabled).containsExactly(
CalendarView.Week, CalendarView.Month, CalendarView.Agenda,
)
}
@Test
fun `drawer order defaults to the implemented order and round-trips`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.drawerViewOrder.first()).containsExactlyElementsIn(IMPLEMENTED_VIEWS).inOrder()
prefs.setDrawerViewOrder(
listOf(CalendarView.Agenda, CalendarView.Day, CalendarView.Week, CalendarView.Month),
)
assertThat(prefs.drawerViewOrder.first()).containsExactly(
CalendarView.Agenda, CalendarView.Day, CalendarView.Week, CalendarView.Month,
).inOrder()
}
@Test
fun `drawer order parse appends missing views and drops unknowns`(@TempDir tempDir: Path) = runTest {
val store = newDataStore(tempDir)
val prefs = SettingsPrefs(store)
store.updateData { p ->
val m = p.toMutablePreferences()
m[SettingsPrefs.DRAWER_VIEW_ORDER_KEY] = "Agenda,Nope,Week"
m
}
assertThat(prefs.drawerViewOrder.first()).containsExactly(
CalendarView.Agenda, CalendarView.Week, CalendarView.Month, CalendarView.Day,
).inOrder()
}
@Test @Test
fun `unknown stored form-field names are dropped`(@TempDir tempDir: Path) = runTest { fun `unknown stored form-field names are dropped`(@TempDir tempDir: Path) = runTest {
val store = newDataStore(tempDir) val store = newDataStore(tempDir)

View File

@@ -91,4 +91,33 @@ class ViewBackStackTest {
assertThat(viewBaseStack(CalendarView.Agenda, CalendarView.Agenda)) assertThat(viewBaseStack(CalendarView.Agenda, CalendarView.Agenda))
.containsExactly(CalendarView.Agenda) .containsExactly(CalendarView.Agenda)
} }
@Test
fun `next cycles through the configured views in order, wrapping around`() {
val cycle = listOf(CalendarView.Month, CalendarView.Agenda)
assertThat(CalendarView.Month.next(cycle)).isEqualTo(CalendarView.Agenda)
// Wraps back to the first from the last.
assertThat(CalendarView.Agenda.next(cycle)).isEqualTo(CalendarView.Month)
}
@Test
fun `next from a view outside the cycle lands on the first enabled view`() {
// Day was disabled in the quick-switch cycle but is still the current view.
val cycle = listOf(CalendarView.Week, CalendarView.Month)
assertThat(CalendarView.Day.next(cycle)).isEqualTo(CalendarView.Week)
}
@Test
fun `next on an empty cycle stays put`() {
assertThat(CalendarView.Week.next(emptyList())).isEqualTo(CalendarView.Week)
}
@Test
fun `quick-switch cycle keeps enabled views in their configured order`() {
val config = QuickSwitchConfig(
order = listOf(CalendarView.Agenda, CalendarView.Day, CalendarView.Month, CalendarView.Week),
enabled = setOf(CalendarView.Agenda, CalendarView.Month),
)
assertThat(config.cycle).containsExactly(CalendarView.Agenda, CalendarView.Month).inOrder()
}
} }