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

Merged
makiolaj merged 17 commits from feat/quick-switch-view-config into release/v2.13.0 2026-07-01 12:09:53 +00:00
24 changed files with 659 additions and 37 deletions

View File

@@ -12,6 +12,8 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.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
@@ -434,6 +464,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")
@@ -446,6 +510,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")

View File

@@ -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))
}
@@ -271,6 +276,8 @@ fun CalendarHost(
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent,
quickSwitchViews = quickSwitchViews,
drawerViewOrder = drawerViewOrder,
)
CalendarView.Day -> DayScreen(
selectedView = currentView,
@@ -280,6 +287,8 @@ fun CalendarHost(
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent,
initialDateIso = pendingDayIso,
quickSwitchViews = quickSwitchViews,
drawerViewOrder = drawerViewOrder,
)
CalendarView.Month -> MonthScreen(
selectedView = currentView,
@@ -288,6 +297,8 @@ fun CalendarHost(
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent,
quickSwitchViews = quickSwitchViews,
drawerViewOrder = drawerViewOrder,
)
CalendarView.Agenda -> AgendaScreen(
selectedView = currentView,
@@ -296,6 +307,8 @@ fun CalendarHost(
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent,
quickSwitchViews = quickSwitchViews,
drawerViewOrder = drawerViewOrder,
)
}
}

View File

@@ -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,
)
}

View File

@@ -64,6 +64,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.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,

View File

@@ -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) },

View File

@@ -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

View File

@@ -142,6 +142,9 @@ fun GroupedRow(
dimmed: Boolean = false,
container: Color? = null,
minHeight: Dp = 72.dp,
// The 2.dp separation between rows in a run. Suppressed by the reorderable
// list, which owns uniform spacing itself so every slot has the same pitch.
gapBelow: Boolean = true,
leading: @Composable (() -> Unit)? = null,
trailing: @Composable (() -> Unit)? = null,
onClick: (() -> Unit)? = null,
@@ -160,9 +163,10 @@ fun GroupedRow(
topStart = small, topEnd = small, bottomStart = full, bottomEnd = full,
)
}
val gap = when (position) {
Position.Top, Position.Middle -> Modifier.padding(bottom = 2.dp)
Position.Bottom, Position.Alone -> Modifier
val gap = when {
!gapBelow -> Modifier
position == Position.Top || position == Position.Middle -> Modifier.padding(bottom = 2.dp)
else -> Modifier
}
val itemColors = if (selected) {
ListItemDefaults.colors(

View File

@@ -0,0 +1,158 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
/** Uniform row height for [ReorderableColumn]; a fixed pitch keeps drag maths exact. */
val ReorderableRowHeight: Dp = 64.dp
private val RowGap: Dp = 2.dp
/**
* 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). Rows are a fixed [ReorderableRowHeight] with a
* uniform gap, so a row's target slot is simply how many whole pitches it has
* been dragged. The held row follows the finger while the others slide out of
* its way (animated); on release the held row settles into its slot, then the
* order is committed with a single [onReorder] call.
*
* [rowContent] receives the [Position] for the row's place in the order (to reuse
* [GroupedRow]'s card shaping — pass `gapBelow = false` there, this owns spacing)
* and a `dragHandle` [Modifier] to attach to the element that starts a drag.
*/
@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,
) {
val pitchPx = with(LocalDensity.current) { (ReorderableRowHeight + RowGap).toPx() }
val scope = rememberCoroutineScope()
// Local working copy; 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) }
// Live translation of the held row from its slot (px); also drives the
// release settle, animated back onto a slot before the order is committed.
var dragOffset by remember { mutableFloatStateOf(0f) }
// The running release/cancel settle, cancelled if a new drag pre-empts it.
var settleJob by remember { mutableStateOf<Job?>(null) }
val draggedIndex = draggedKey?.let { key -> order.indexOfFirst { keyOf(it) == key }.takeIf { it >= 0 } }
// Whole slots dragged → the slot the held row currently hovers over.
val targetIndex = draggedIndex?.let {
(it + (dragOffset / pitchPx).roundToInt()).coerceIn(0, order.lastIndex)
}
Column(modifier, verticalArrangement = Arrangement.spacedBy(RowGap)) {
order.forEachIndexed { index, item ->
val key = keyOf(item)
val isDragged = key == draggedKey
// Slide neighbours by one pitch to open the gap the held row will drop
// into. Snap (not animate) once idle, so committing the new order — which
// moves each row's slot — doesn't visibly fight a lingering animation.
val shift = when {
draggedIndex == null || targetIndex == null || isDragged -> 0f
index in (draggedIndex + 1)..targetIndex -> -pitchPx
index in targetIndex until draggedIndex -> pitchPx
else -> 0f
}
val animatedShift by animateFloatAsState(
targetValue = shift,
animationSpec = if (draggedKey != null) spring(stiffness = Spring.StiffnessMediumLow) else snap(),
label = "reorderShift",
)
val dragHandle = Modifier.pointerInput(key) {
detectDragGestures(
onDragStart = {
settleJob?.cancel()
draggedKey = key
dragOffset = 0f
},
onDrag = { change, amount ->
change.consume()
dragOffset += amount.y
},
onDragEnd = {
val from = order.indexOfFirst { keyOf(it) == key }
if (from < 0) return@detectDragGestures
val to = (from + (dragOffset / pitchPx).roundToInt()).coerceIn(0, order.lastIndex)
settleJob = scope.launch {
// Settle the held row onto its target slot, then commit —
// resetting offset and slot in the same frame, so nothing jumps.
Animatable(dragOffset).animateTo((to - from) * pitchPx, tween(160)) {
dragOffset = value
}
if (to != from) {
order = order.toMutableList().apply { add(to, removeAt(from)) }
onReorder(order)
}
draggedKey = null
dragOffset = 0f
}
},
onDragCancel = {
settleJob = scope.launch {
Animatable(dragOffset).animateTo(0f, tween(160)) { dragOffset = value }
draggedKey = null
dragOffset = 0f
}
},
)
}
Box(
Modifier
.height(ReorderableRowHeight)
.zIndex(if (isDragged) 1f else 0f)
.graphicsLayer {
translationY = if (isDragged) dragOffset else animatedShift
if (isDragged) {
scaleX = 1.02f
scaleY = 1.02f
shadowElevation = 8.dp.toPx()
shape = RoundedCornerShape(20.dp)
clip = false
}
},
) {
rowContent(item, positionOf(index, order.size), dragHandle, isDragged)
}
}
}
}

View File

@@ -72,6 +72,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.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,

View File

@@ -68,6 +68,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.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,

View File

@@ -41,6 +41,8 @@ import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.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,14 @@ 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.ReorderableRowHeight
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 +128,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 +173,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 +218,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 +685,131 @@ 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 = ReorderableRowHeight,
// The reorderable column owns the inter-row spacing (uniform pitch).
gapBelow = false,
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,

View File

@@ -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). */

View File

@@ -21,6 +21,7 @@ import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.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) }
}

View File

@@ -81,6 +81,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.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,

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Calendula</string>
<string name="app_tagline">Ein moderner Kalender.</string>
<string name="state_loading">Lädt…</string>
<string name="state_retry">Erneut versuchen</string>
<string name="state_failure_unknown">Etwas ist schiefgelaufen.</string>
@@ -10,7 +10,6 @@
<string name="state_failure_no_calendars">Keine Kalender eingerichtet.</string>
<string name="state_failure_no_calendars_action">System-Kalender-Einstellungen öffnen</string>
<string name="state_failure_provider">Kalender konnte nicht gelesen werden.</string>
<!-- Permission-Flow (F1) -->
<string name="permission_rationale_title">Alle Termine, schön im Blick</string>
<string name="permission_rationale_body">Calendula braucht Zugriff auf deinen Kalender, um deine Termine zu zeigen und zu verwalten. Mehr verlangt die App nie.</string>
@@ -26,7 +25,6 @@
<string name="permission_benefit_privacy_title">Kein Tracking, niemals</string>
<string name="permission_benefit_privacy_body">Keine Telemetrie, keine Analyse, keine Werbung.</string>
<string name="permission_privacy_footnote">Bleibt auf deinem Gerät · keine Internet-Berechtigung</string>
<!-- Monatsansicht (S1) -->
<string name="month_prev">Vorheriger Monat</string>
<string name="month_next">Nächster Monat</string>
@@ -35,14 +33,11 @@
<string name="month_open_menu">Menü öffnen</string>
<string name="month_action_settings">Einstellungen</string>
<string name="month_a11y_today_prefix">Heute</string>
<!-- Wochenansicht (S2) -->
<string name="week_today_action">Diese Woche</string>
<string name="week_number_label">KW</string>
<!-- Tagesansicht (S3) -->
<string name="day_today_action">Heute</string>
<!-- Event-Detail-Screen (S4) -->
<string name="event_detail_back">Zurück</string>
<string name="event_detail_edit">Bearbeiten</string>
@@ -61,7 +56,6 @@
<string name="event_delete_write_denied">Calendula braucht Schreibzugriff, um Termine zu löschen</string>
<string name="dialog_cancel">Abbrechen</string>
<string name="dialog_ok">OK</string>
<!-- Termin-Formular (v1.2 Erstellen) -->
<string name="event_edit_new_title">Neuer Termin</string>
<string name="event_edit_close">Schließen</string>
@@ -94,7 +88,6 @@
<string name="reminder_unit_weeks">Wochen</string>
<string name="event_edit_availability">Verfügbarkeit</string>
<string name="event_edit_visibility">Sichtbarkeit</string>
<!-- Termin-Formular — eigene Terminfarbe -->
<string name="event_edit_color">Farbe</string>
<string name="event_edit_color_default">Kalenderfarbe</string>
@@ -103,7 +96,6 @@
<string name="event_edit_color_unsupported">Für diesen Kalender nicht verfügbar</string>
<string name="event_edit_color_unsupported_hint">Dieser Kalender stellt keine Farbpalette bereit. Du kannst eigene Farben für solche Kalender in den Einstellungen aktivieren.</string>
<string name="event_edit_color_sync_warning">Dieser Kalender verwirft oder überschreibt die Farbe unter Umständen bei der nächsten Synchronisierung.</string>
<!-- Termin-Formular — Speicher-Konflikt (v2.0) -->
<string name="event_edit_conflict_title">Termin wurde extern geändert</string>
<string name="event_edit_conflict_body">Während du bearbeitet hast, wurde dieser Termin anderswo geändert — durch Synchronisierung oder eine andere App. Was soll mit deinen Änderungen passieren?</string>
@@ -113,7 +105,6 @@
<string name="event_edit_conflict_discard_hint">Der Termin bleibt, wie er jetzt ist</string>
<string name="event_edit_gone_title">Termin wurde gelöscht</string>
<string name="event_edit_gone_body">Dieser Termin wurde zwischenzeitlich gelöscht, etwa auf einem anderen Gerät. Deine Änderungen können nicht mehr gespeichert werden.</string>
<!-- Termin-Formular — Wiederholungs-Picker (v1.3) -->
<string name="event_edit_recurrence_none">Wiederholt sich nicht</string>
<string name="event_edit_recurrence_custom">Benutzerdefiniert</string>
@@ -156,7 +147,6 @@
<string name="event_attendee_tentative">Vorläufig</string>
<string name="event_attendee_needs_action">Keine Antwort</string>
<string name="event_attendee_unknown"></string>
<!-- Event-Detail — vollständiges Auslesen (v0.6) -->
<string name="event_detail_reminders">Erinnerungen</string>
<string name="event_detail_timezone">Zeitzone</string>
@@ -196,10 +186,8 @@
<item quantity="one">%d Stunde</item>
<item quantity="other">%d Stunden</item>
</plurals>
<!-- Geteilte Event-Strings -->
<string name="event_untitled">(Ohne Titel)</string>
<!-- Erinnerungs-Benachrichtigungen (v1.4) -->
<string name="reminder_channel_name">Termin-Erinnerungen</string>
<string name="reminder_channel_description">Benachrichtigungen zu den Erinnerungszeiten deiner Termine</string>
@@ -215,23 +203,19 @@
<string name="reminder_onboarding_skip_button">Später</string>
<string name="reminder_action_snooze">Schlummern</string>
<string name="reminder_action_dismiss">Verwerfen</string>
<!-- View-Switcher (M1) -->
<string name="view_month">Monat</string>
<string name="view_week">Woche</string>
<string name="view_day">Tag</string>
<string name="view_agenda">Agenda</string>
<string name="view_section">Ansicht</string>
<!-- Zu Datum springen (Navigationsleiste) -->
<string name="drawer_jump_to_date">Zu Datum springen</string>
<!-- Agenda-Ansicht -->
<string name="agenda_today_action">Heute</string>
<string name="agenda_header_today">Heute</string>
<string name="agenda_header_tomorrow">Morgen</string>
<string name="agenda_empty_title">Alles erledigt</string>
<!-- Terminsuche -->
<string name="search_action">Suchen</string>
<string name="search_hint">Termine suchen</string>
@@ -239,7 +223,6 @@
<string name="search_clear">Löschen</string>
<string name="search_idle_hint">Durchsuche deine Termine nach Titel, Ort oder Notizen.</string>
<string name="search_empty">Keine Termine passen zu „%1$s“.</string>
<!-- Startbildschirm-Widgets -->
<string name="widget_agenda_title">Anstehend</string>
<string name="widget_agenda_label">Calendula Agenda</string>
@@ -250,19 +233,15 @@
<string name="widget_prev_month">Vorheriger Monat</string>
<string name="widget_next_month">Nächster Monat</string>
<string name="widget_today">Heute</string>
<!-- Verknüpfungen (Long-Press auf das App-Symbol) -->
<string name="shortcut_new_event_short">Neuer Termin</string>
<string name="shortcut_new_event_long">Neuen Termin erstellen</string>
<!-- Schnelleinstellungen-Kachel -->
<string name="qs_tile_new_event_label">Neuer Termin</string>
<string name="settings_qs_tile">Schnelleinstellungen-Kachel hinzufügen</string>
<string name="settings_qs_tile_hint">Eine Kachel „Neuer Termin“ zu den Schnelleinstellungen hinzufügen.</string>
<!-- Kalender-Filter (M3) -->
<string name="filter_title">Kalender</string>
<!-- Einstellungen (M4) -->
<string name="settings_title">Einstellungen</string>
<string name="settings_back">Zurück</string>
@@ -343,7 +322,6 @@
<string name="settings_about_logo_desc">Calendula-App-Symbol</string>
<string name="settings_report_problem">Problem melden</string>
<string name="settings_report_problem_hint">Absturzbericht senden oder Issue-Tracker öffnen</string>
<!-- Calendar manager -->
<string name="calendars_title">Kalender</string>
<string name="calendars_local_header">Deine Kalender</string>
@@ -398,7 +376,6 @@
<item quantity="one">%d bereits in diesem Kalender übersprungen.</item>
<item quantity="other">%d bereits in diesem Kalender übersprungen.</item>
</plurals>
<!-- Absturzberichte: vom Nutzer selbst als Gitea-Issue einreichbar -->
<string name="crash_dialog_title">Calendula ist abgestürzt</string>
<string name="crash_dialog_message">Calendula wurde beim letzten Mal unerwartet beendet. Du kannst bei der Behebung helfen, indem du diesen Bericht als Issue sendest. Er bleibt auf deinem Gerät, bis du ihn teilst, und enthält keine persönlichen Daten oder Kalenderinhalte — nur die technischen Angaben unten.</string>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="backup_interval_units">
<item>Minutes</item>
<item>Hours</item>
<item>Days</item>
<item>Weeks</item>
</string-array>
</resources>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="backup_interval_units">
<item>Minutes</item>
<item>Hours</item>
<item>Days</item>
<item>Weeks</item>
</string-array>
</resources>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="backup_interval_units">
<item>Minutes</item>
<item>Hours</item>
<item>Days</item>
<item>Weeks</item>
</string-array>
</resources>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="backup_interval_units">
<item>Minutes</item>
<item>Hours</item>
<item>Days</item>
<item>Weeks</item>
</string-array>
</resources>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="backup_interval_units">
<item>Minutes</item>
<item>Hours</item>
<item>Days</item>
<item>Weeks</item>
</string-array>
</resources>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="backup_interval_units">
<item>Minutes</item>
<item>Hours</item>
<item>Days</item>
<item>Weeks</item>
</string-array>
</resources>

View File

@@ -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>
@@ -342,6 +348,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>

View File

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

View File

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