From b9e800e9fc452a6826eddbbba0cc8e952785d736 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 1 Jul 2026 12:19:50 +0200 Subject: [PATCH] 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 --- .../calendula/data/prefs/SettingsPrefs.kt | 66 ++++++++ .../calendula/ui/CalendarHost.kt | 13 ++ .../calendula/ui/CalendarHostViewModel.kt | 19 +++ .../calendula/ui/agenda/AgendaScreen.kt | 6 +- .../calendula/ui/common/CalendarDrawer.kt | 5 +- .../calendula/ui/common/CalendarView.kt | 30 +++- .../calendula/ui/common/ReorderableColumn.kt | 103 +++++++++++++ .../calendula/ui/day/DayScreen.kt | 6 +- .../calendula/ui/month/MonthScreen.kt | 6 +- .../calendula/ui/settings/SettingsScreen.kt | 145 +++++++++++++++++- .../calendula/ui/settings/SettingsUiState.kt | 6 + .../ui/settings/SettingsViewModel.kt | 27 +++- .../calendula/ui/week/WeekScreen.kt | 6 +- app/src/main/res/values/strings.xml | 7 + .../calendula/data/prefs/SettingsPrefsTest.kt | 75 +++++++++ .../calendula/ui/common/ViewBackStackTest.kt | 29 ++++ 16 files changed, 539 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ReorderableColumn.kt 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 1a1f1d5..a6a6ee6 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 @@ -12,6 +12,8 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange 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 kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.datetime.DayOfWeek @@ -202,6 +204,34 @@ class SettingsPrefs @Inject constructor( 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 = 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> = store.data.map { prefs -> + parseViewOrder(prefs[DRAWER_VIEW_ORDER_KEY]) + } + + suspend fun setDrawerViewOrder(order: List) { + store.edit { it[DRAWER_VIEW_ORDER_KEY] = order.joinToString(",") { view -> view.name } } + } + /** * Optional event-form fields shown by default (the rest hide behind * "more fields"). Stored comma-joined by enum name: an absent key means @@ -433,6 +463,40 @@ class SettingsPrefs @Inject constructor( .toSet() } + /** Parse a plain comma-joined view order, completed to every implemented view. */ + private fun parseViewOrder(stored: String?): List = + 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): List { + val ordered = seen.distinct() + return ordered + IMPLEMENTED_VIEWS.filterNot { it in ordered } + } + companion object { internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode") 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 DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events") 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 AUTOFOCUS_EVENT_TITLE_KEY = booleanPreferencesKey("autofocus_event_title") internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled") diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt index 50ee186..6cddce2 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -79,6 +79,11 @@ fun CalendarHost( // correcting it. Brief blank first frame, matching the onboarding gate above. 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) { mutableStateOf(listOf(defaultView)) } @@ -260,6 +265,8 @@ fun CalendarHost( onOpenSettings = onOpenSettings, onOpenSearch = onOpenSearch, onCreateEvent = onCreateEvent, + quickSwitchViews = quickSwitchViews, + drawerViewOrder = drawerViewOrder, ) CalendarView.Day -> DayScreen( selectedView = currentView, @@ -269,6 +276,8 @@ fun CalendarHost( onOpenSearch = onOpenSearch, onCreateEvent = onCreateEvent, initialDateIso = pendingDayIso, + quickSwitchViews = quickSwitchViews, + drawerViewOrder = drawerViewOrder, ) CalendarView.Month -> MonthScreen( selectedView = currentView, @@ -277,6 +286,8 @@ fun CalendarHost( onOpenSettings = onOpenSettings, onOpenSearch = onOpenSearch, onCreateEvent = onCreateEvent, + quickSwitchViews = quickSwitchViews, + drawerViewOrder = drawerViewOrder, ) CalendarView.Agenda -> AgendaScreen( selectedView = currentView, @@ -285,6 +296,8 @@ fun CalendarHost( onOpenSettings = onOpenSettings, onOpenSearch = onOpenSearch, onCreateEvent = onCreateEvent, + quickSwitchViews = quickSwitchViews, + drawerViewOrder = drawerViewOrder, ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHostViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHostViewModel.kt index 9a9c2b7..773f76d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHostViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHostViewModel.kt @@ -5,8 +5,10 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.ui.common.CalendarView +import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import javax.inject.Inject @@ -26,4 +28,21 @@ class CalendarHostViewModel @Inject constructor( started = SharingStarted.WhileSubscribed(5_000L), initialValue = null, ) + + /** Views the top-bar quick-switch pill cycles through, in the user's order (#24). */ + val quickSwitchViews: StateFlow> = 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> = prefs.drawerViewOrder + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = IMPLEMENTED_VIEWS, + ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt index 4db125c..8286ee8 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt @@ -64,6 +64,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn import de.jeanlucmakiola.calendula.ui.common.CalendarFailure 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.GroupedRow import de.jeanlucmakiola.calendula.ui.common.Position @@ -96,6 +97,8 @@ fun AgendaScreen( onOpenSettings: () -> Unit, onOpenSearch: () -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit, + quickSwitchViews: List = IMPLEMENTED_VIEWS, + drawerViewOrder: List = IMPLEMENTED_VIEWS, modifier: Modifier = Modifier, viewModel: AgendaViewModel = hiltViewModel(), ) { @@ -120,6 +123,7 @@ fun AgendaScreen( CalendarDrawer( currentView = selectedView, currentDate = anchor, + viewOrder = drawerViewOrder, onSelectView = { view -> onSelectView(view) scope.launch { drawerState.close() } @@ -140,7 +144,7 @@ fun AgendaScreen( topBar = { AgendaTopBar( selectedView = selectedView, - onCycleView = { onSelectView(selectedView.next()) }, + onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) }, onOpenDrawer = { scope.launch { drawerState.open() } }, onOpenSearch = onOpenSearch, scrollBehavior = scrollBehavior, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarDrawer.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarDrawer.kt index 2b66b98..37b2c29 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarDrawer.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarDrawer.kt @@ -59,6 +59,7 @@ fun CalendarDrawer( onSelectView: (CalendarView) -> Unit, onJumpToDate: (LocalDate) -> Unit, onSettings: () -> Unit, + viewOrder: List = IMPLEMENTED_VIEWS, ) { var showDatePicker by remember { mutableStateOf(false) } @@ -73,10 +74,10 @@ fun CalendarDrawer( DrawerHeader() DrawerSectionHeader(stringResource(R.string.view_section)) - IMPLEMENTED_VIEWS.forEachIndexed { index, view -> + viewOrder.forEachIndexed { index, view -> GroupedRow( title = stringResource(view.labelRes), - position = positionOf(index, IMPLEMENTED_VIEWS.size), + position = positionOf(index, viewOrder.size), selected = view == currentView, minHeight = 56.dp, leading = { Icon(view.icon, contentDescription = null) }, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarView.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarView.kt index 57151ab..b701d95 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarView.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarView.kt @@ -45,11 +45,39 @@ val IMPLEMENTED_VIEWS: List = /** Next view in [available], wrapping around. Falls back to Month if absent. */ fun CalendarView.next(available: List = IMPLEMENTED_VIEWS): CalendarView { + if (available.isEmpty()) return 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] } +/** + * 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, + val enabled: Set, +) { + /** Views the pill steps through, in [order]. */ + val cycle: List 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 * sits at the bottom. Pressing back pops one level until only the home view diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ReorderableColumn.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ReorderableColumn.kt new file mode 100644 index 0000000..369ee3a --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/ReorderableColumn.kt @@ -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 ReorderableColumn( + items: List, + keyOf: (T) -> Any, + onReorder: (List) -> 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(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() } + + 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) + } + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt index 97cbdce..ce43538 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt @@ -72,6 +72,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn import de.jeanlucmakiola.calendula.ui.common.CalendarFailure 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.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition @@ -116,6 +117,8 @@ fun DayScreen( onOpenSettings: () -> Unit, onOpenSearch: () -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit, + quickSwitchViews: List = IMPLEMENTED_VIEWS, + drawerViewOrder: List = IMPLEMENTED_VIEWS, modifier: Modifier = Modifier, initialDateIso: String? = null, viewModel: DayViewModel = hiltViewModel(), @@ -175,6 +178,7 @@ fun DayScreen( CalendarDrawer( currentView = selectedView, currentDate = date, + viewOrder = drawerViewOrder, onSelectView = { view -> onSelectView(view) scope.launch { drawerState.close() } @@ -196,7 +200,7 @@ fun DayScreen( DayTopBar( date = date, selectedView = selectedView, - onCycleView = { onSelectView(selectedView.next()) }, + onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) }, onOpenDrawer = { scope.launch { drawerState.open() } }, onOpenSearch = onOpenSearch, scrollBehavior = scrollBehavior, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index f57f6e3..3413ce7 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -68,6 +68,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn import de.jeanlucmakiola.calendula.ui.common.CalendarFailure 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.LocalDimCutoff import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute @@ -98,6 +99,8 @@ fun MonthScreen( onOpenSettings: () -> Unit, onOpenSearch: () -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit, + quickSwitchViews: List = IMPLEMENTED_VIEWS, + drawerViewOrder: List = IMPLEMENTED_VIEWS, modifier: Modifier = Modifier, viewModel: MonthViewModel = hiltViewModel(), ) { @@ -157,6 +160,7 @@ fun MonthScreen( CalendarDrawer( currentView = selectedView, currentDate = LocalDate(month.year, month.month, 1), + viewOrder = drawerViewOrder, onSelectView = { view -> onSelectView(view) scope.launch { drawerState.close() } @@ -178,7 +182,7 @@ fun MonthScreen( MonthTopBar( month = month, selectedView = selectedView, - onCycleView = { onSelectView(selectedView.next()) }, + onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) }, onOpenDrawer = { scope.launch { drawerState.open() } }, onOpenSearch = onOpenSearch, scrollBehavior = scrollBehavior, 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 d3fc450..3c3c97a 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 @@ -41,6 +41,8 @@ import androidx.compose.material.icons.filled.BugReport import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.Dashboard 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.ExpandMore 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.AgendaRangePicker 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.GroupedRow 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.CalendarColorChip import de.jeanlucmakiola.calendula.ui.common.OptionPicker @@ -121,7 +127,7 @@ import java.time.format.TextStyle as JavaTextStyle import java.util.Calendar /** 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). @@ -166,6 +172,13 @@ fun SettingsScreen( ) { 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( visible = section == SettingsSection.EventForm, enter = slideInHorizontally(slideSpec) { it } + fadeIn(), @@ -204,6 +217,13 @@ private fun SettingsHub( leading = { CategoryIcon(Icons.Default.Palette, ChipAccent.Neutral) }, 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( title = stringResource(R.string.settings_section_event_form), 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 private fun EventFormScreen( state: SettingsUiState, 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 52c23ae..43667f9 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 @@ -9,6 +9,8 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventFormField 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 /** * Settings screen state (M4). Persisted preferences are instant to read, so @@ -37,6 +39,10 @@ data class SettingsUiState( val agendaShowRangeBar: Boolean = true, /** The calendar view the app opens on, and the home of the view back stack (M1). */ 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 = IMPLEMENTED_VIEWS, /** Optional event-form fields shown by default (rest behind "more fields"). */ val defaultFormFields: Set = SettingsPrefs.DEFAULT_FORM_FIELDS, /** Whether the new-event form auto-focuses the title and shows the keyboard (#10). */ 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 557ab21..cc1b263 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 @@ -21,6 +21,7 @@ import de.jeanlucmakiola.calendula.domain.EventFormField 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.widget.agenda.AGENDA_PAST_DISPLAY_KEY import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_RANGE_KEY import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget @@ -103,8 +104,14 @@ class SettingsViewModel @Inject constructor( prefs.autofocusEventTitle, prefs.pastEventDisplay, 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.copy( defaultView = views.defaultView, @@ -116,6 +123,8 @@ class SettingsViewModel @Inject constructor( autofocusEventTitle = misc.autofocusEventTitle, pastEventDisplay = misc.pastEventDisplay, dimCompletedEvents = misc.dimCompletedEvents, + quickSwitchConfig = misc.viewCustomization.quickSwitch, + drawerViewOrder = misc.viewCustomization.drawerOrder, allowColorOnUnsupportedCalendars = defaults.allowColor, defaultReminderMinutes = defaults.defaultReminder, defaultAllDayReminderMinutes = defaults.allDayReminder, @@ -164,6 +173,12 @@ class SettingsViewModel @Inject constructor( val autofocusEventTitle: Boolean, val pastEventDisplay: PastEventDisplay, val dimCompletedEvents: Boolean, + val viewCustomization: ViewCustomization, + ) + + private data class ViewCustomization( + val quickSwitch: QuickSwitchConfig, + val drawerOrder: List, ) fun setThemeMode(mode: ThemeMode) { @@ -247,6 +262,14 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { prefs.setFormFieldDefault(field, enabled) } } + fun setQuickSwitchConfig(config: QuickSwitchConfig) { + viewModelScope.launch { prefs.setQuickSwitchConfig(config) } + } + + fun setDrawerViewOrder(order: List) { + viewModelScope.launch { prefs.setDrawerViewOrder(order) } + } + fun setRemindersEnabled(enabled: Boolean) { viewModelScope.launch { prefs.setRemindersEnabled(enabled) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index e91de43..c40ab29 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -81,6 +81,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn import de.jeanlucmakiola.calendula.ui.common.CalendarFailure 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.LocalDimCutoff import de.jeanlucmakiola.calendula.ui.common.NowLine @@ -131,6 +132,8 @@ fun WeekScreen( onOpenSettings: () -> Unit, onOpenSearch: () -> Unit, onCreateEvent: (LocalDate, Int?) -> Unit, + quickSwitchViews: List = IMPLEMENTED_VIEWS, + drawerViewOrder: List = IMPLEMENTED_VIEWS, modifier: Modifier = Modifier, viewModel: WeekViewModel = hiltViewModel(), ) { @@ -195,6 +198,7 @@ fun WeekScreen( CalendarDrawer( currentView = selectedView, currentDate = weekStart, + viewOrder = drawerViewOrder, onSelectView = { view -> onSelectView(view) scope.launch { drawerState.close() } @@ -216,7 +220,7 @@ fun WeekScreen( WeekTopBar( weekStart = weekStart, selectedView = selectedView, - onCycleView = { onSelectView(selectedView.next()) }, + onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) }, onOpenDrawer = { scope.launch { drawerState.open() } }, onOpenSearch = onOpenSearch, scrollBehavior = scrollBehavior, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d121f90..334e980 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -308,6 +308,12 @@ %d day %d days + Views + 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 + Drag to reorder the views listed in the navigation menu. + Drag to reorder New event form Fields shown by default — everything else sits behind \"More fields\" Focus title on new event @@ -343,6 +349,7 @@ Add or improve a language on Weblate Theme, default view, week start + Quick-switch button and menu order Default fields for new events Event reminders About 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 eec1075..ef2f097 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 @@ -6,6 +6,9 @@ import androidx.datastore.preferences.core.Preferences import com.google.common.truth.Truth.assertThat import de.jeanlucmakiola.calendula.domain.EventFormField 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.test.runTest import kotlinx.datetime.DayOfWeek @@ -149,6 +152,78 @@ class SettingsPrefsTest { 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 fun `unknown stored form-field names are dropped`(@TempDir tempDir: Path) = runTest { val store = newDataStore(tempDir) diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ViewBackStackTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ViewBackStackTest.kt index 7c4f118..93d5028 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ViewBackStackTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/ViewBackStackTest.kt @@ -91,4 +91,33 @@ class ViewBackStackTest { assertThat(viewBaseStack(CalendarView.Agenda, 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() + } }