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.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")
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) },
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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). */
|
||||||
|
|||||||
@@ -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) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user