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:
@@ -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<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
|
||||
* "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<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 {
|
||||
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")
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
drawerViewOrder: List<CalendarView> = 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,
|
||||
|
||||
@@ -59,6 +59,7 @@ fun CalendarDrawer(
|
||||
onSelectView: (CalendarView) -> Unit,
|
||||
onJumpToDate: (LocalDate) -> Unit,
|
||||
onSettings: () -> Unit,
|
||||
viewOrder: List<CalendarView> = 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) },
|
||||
|
||||
@@ -45,11 +45,39 @@ val IMPLEMENTED_VIEWS: List<CalendarView> =
|
||||
|
||||
/** Next view in [available], wrapping around. Falls back to Month if absent. */
|
||||
fun CalendarView.next(available: List<CalendarView> = 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<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
|
||||
* sits at the bottom. Pressing back pops one level until only the home view
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
drawerViewOrder: List<CalendarView> = 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,
|
||||
|
||||
@@ -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<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
drawerViewOrder: List<CalendarView> = 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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
/** Optional event-form fields shown by default (rest behind "more fields"). */
|
||||
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
|
||||
/** Whether the new-event form auto-focuses the title and shows the keyboard (#10). */
|
||||
|
||||
@@ -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<CalendarView>,
|
||||
)
|
||||
|
||||
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<CalendarView>) {
|
||||
viewModelScope.launch { prefs.setDrawerViewOrder(order) }
|
||||
}
|
||||
|
||||
fun setRemindersEnabled(enabled: Boolean) {
|
||||
viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
|
||||
}
|
||||
|
||||
@@ -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<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
drawerViewOrder: List<CalendarView> = 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,
|
||||
|
||||
@@ -308,6 +308,12 @@
|
||||
<item quantity="one">%d day</item>
|
||||
<item quantity="other">%d days</item>
|
||||
</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_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>
|
||||
@@ -343,6 +349,7 @@
|
||||
<string name="settings_translate_hint">Add or improve a language on Weblate</string>
|
||||
<!-- Hub category subtitles -->
|
||||
<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_notifications_subtitle">Event reminders</string>
|
||||
<string name="settings_section_about">About</string>
|
||||
|
||||
Reference in New Issue
Block a user