feat(android): toggle month view between scroll feed and paged swipe

New device-local setting "Monatsansicht seitenweise wischen" (SettingsStore,
no server sync). When off, the month view stays the continuous vertical scroll
feed. When on, it becomes a HorizontalPager — one month per screen, six
height-filling week rows, swipe left/right to change month. The prev/next/today
signals route to the pager (animateScrollToPage) instead of the list, and the
visible month is derived from pagerState.currentPage. WeekRow now takes a
rowModifier so the same row renders at a fixed height (scroll) or weight(1f)
(paged). Setting is re-read when the settings screen closes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Guido Schmit
2026-07-07 22:47:26 +02:00
parent 8d7ac124ae
commit e49a2e027f
7 changed files with 145 additions and 45 deletions

View File

@@ -62,6 +62,12 @@ class SettingsStore @Inject constructor(
get() = prefs.getInt(K_CACHE_MONTHS, 3)
set(value) = prefs.edit().putInt(K_CACHE_MONTHS, value).apply()
/** Device-local: month view as horizontal paged (swipe) instead of the
* continuous vertical scroll feed (default false = scroll). */
var monthViewPaged: Boolean
get() = prefs.getBoolean(K_MONTH_PAGED, false)
set(value) = prefs.edit().putBoolean(K_MONTH_PAGED, value).apply()
// --- Hidden calendars ("source:id") ---
var hiddenCalendarKeys: Set<String>
@@ -99,6 +105,7 @@ class SettingsStore @Inject constructor(
const val K_DEFAULT_REMINDER = "default_reminder_minutes"
const val K_DEFAULT_DURATION = "default_event_duration_minutes"
const val K_CACHE_MONTHS = "cache_months"
const val K_MONTH_PAGED = "month_view_paged"
const val K_HIDDEN = "hidden_calendar_keys"
const val K_BANISHED = "banished_calendar_keys"
const val K_REMINDER_DISABLED = "reminder_disabled_calendar_keys"

View File

@@ -63,6 +63,7 @@ object L10n {
"settings.calview" to "Kalenderansicht", "settings.defaultview" to "Standardansicht",
"settings.firstweekday" to "Erster Wochentag", "settings.monday" to "Montag",
"settings.sunday" to "Sonntag", "settings.dimpast" to "Vergangene Termine ausgrauen",
"settings.month_paged" to "Monatsansicht seitenweise wischen",
"settings.hourheight" to "Stundenhöhe",
"settings.hourheight.compact" to "Kompakt", "settings.hourheight.normal" to "Normal",
"settings.hourheight.comfort" to "Komfort", "settings.hourheight.large" to "Gross",
@@ -208,6 +209,7 @@ object L10n {
"settings.calview" to "Calendar view", "settings.defaultview" to "Default view",
"settings.firstweekday" to "First day of week", "settings.monday" to "Monday",
"settings.sunday" to "Sunday", "settings.dimpast" to "Dim past events",
"settings.month_paged" to "Swipe month view as pages",
"settings.hourheight" to "Hour height",
"settings.hourheight.compact" to "Compact", "settings.hourheight.normal" to "Normal",
"settings.hourheight.comfort" to "Comfort", "settings.hourheight.large" to "Large",

View File

@@ -299,7 +299,7 @@ fun CalendarScreen(
when (overlay) {
Overlay.PROFILE -> ProfileScreen(onClose = { overlay = Overlay.NONE })
Overlay.SETTINGS -> SettingsScreen(
onClose = { overlay = Overlay.NONE },
onClose = { overlay = Overlay.NONE; vm.refreshMonthViewMode() },
onSettingsChanged = onSettingsChanged,
onSettingsSynced = onSettingsSynced,
)

View File

@@ -58,6 +58,8 @@ data class CalendarUiState(
// Full calendar list across all sources (loaded on demand) so the filter
// shows every calendar, including ones with no events in the loaded range.
val allCalendars: List<CalendarFilterEntry> = emptyList(),
// Device-local: month view as horizontal paged (swipe) vs. scroll feed.
val monthViewPaged: Boolean = false,
)
fun groupMemberKey(ownerId: Int): String = "gm:$ownerId"
@@ -122,9 +124,15 @@ class CalendarViewModel @Inject constructor(
hiddenKeys = settingsStore.hiddenCalendarKeys,
banishedKeys = settingsStore.banishedCalendarKeys,
reminderDisabledKeys = settingsStore.reminderDisabledCalendarKeys,
monthViewPaged = settingsStore.monthViewPaged,
)
}
/** Re-read the device-local month-view mode (called when settings close). */
fun refreshMonthViewMode() {
_state.update { it.copy(monthViewPaged = settingsStore.monthViewPaged) }
}
/** Default duration (minutes) for a new event's end time. */
val defaultEventDurationMinutes: Int get() = settingsStore.loadSettings().defaultEventDurationMinutes

View File

@@ -16,6 +16,8 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -79,7 +81,10 @@ private data class PlacedBar(
private class WeekLayout(val bars: List<PlacedBar>, val overflowPerCol: IntArray)
/** Continuous, vertically scrolling month calendar with multi-day event bars (iOS-style). */
/** Month calendar with multi-day event bars (iOS-style). Two modes: a
* continuous vertical scroll feed, or a horizontally-paged one-month-per-screen
* grid (swipe left/right), toggled by [CalendarUiState.monthViewPaged]. */
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MonthView(
state: CalendarUiState,
@@ -127,6 +132,39 @@ fun MonthView(
val todayIndex = remember(firstVisible) { weekIndexOf(today) }
// Month-paged mode bookkeeping (one page per calendar month).
val firstMonth = remember { today.withDayOfMonth(1).minusMonths(MONTHS_BACK) }
val monthCount = remember { (MONTHS_BACK + MONTHS_AHEAD + 1).toInt() }
fun monthIndexOf(date: LocalDate): Int =
ChronoUnit.MONTHS.between(firstMonth, date.withDayOfMonth(1)).toInt().coerceIn(0, monthCount - 1)
val todayMonthIndex = remember(firstMonth) { monthIndexOf(today) }
val pagerState = rememberPagerState(initialPage = todayMonthIndex) { monthCount }
val eventsByWeek = remember(state.events, mondayFirst) { buildEventsByWeek(state.events, mondayFirst) }
val dimPast = settings.dimPastEvents
val cwLabel = tr("cal.cw")
// remember: a fresh Instant per recomposition would invalidate every visible
// WeekRow (the main source of scroll jank); minute precision is plenty here.
val now = remember { java.time.Instant.now() }
// Route the title / prev-next / today signals to the active surface (pager or list).
if (state.monthViewPaged) {
LaunchedEffect(scrollToTodaySignal) {
if (scrollToTodaySignal > 0) pagerState.animateScrollToPage(todayMonthIndex)
}
LaunchedEffect(monthJumpSignal) {
if (monthJumpSignal > 0 && monthJumpTarget != null) pagerState.animateScrollToPage(monthIndexOf(monthJumpTarget))
}
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.currentPage }
.map { firstMonth.plusMonths(it.toLong()) }
.distinctUntilChanged()
.collect { month ->
onVisibleMonthChange(month)
vm.ensureMonthLoaded(month)
}
}
} else {
LaunchedEffect(Unit) { listState.scrollToItem((todayIndex - 1).coerceAtLeast(0)) }
LaunchedEffect(scrollToTodaySignal) {
if (scrollToTodaySignal > 0) listState.animateScrollToItem((todayIndex - 1).coerceAtLeast(0))
@@ -145,13 +183,7 @@ fun MonthView(
vm.ensureMonthLoaded(month)
}
}
val eventsByWeek = remember(state.events, mondayFirst) { buildEventsByWeek(state.events, mondayFirst) }
val dimPast = settings.dimPastEvents
val cwLabel = tr("cal.cw")
// remember: a fresh Instant per recomposition would invalidate every visible
// WeekRow (the main source of scroll jank); minute precision is plenty here.
val now = remember { java.time.Instant.now() }
}
Column(Modifier.fillMaxSize()) {
Row(Modifier.fillMaxWidth().padding(vertical = 3.dp)) {
@@ -166,13 +198,19 @@ fun MonthView(
}
}
LazyColumn(
state = listState,
if (state.monthViewPaged) {
// One month per page, six height-filling week rows, swipe to change month.
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize().onSizeChanged { gridWidthPx = it.width },
) {
items(weekCount, key = { it }, contentType = { "week" }) { index ->
val weekStart = firstVisible.plusWeeks(index.toLong())
) { page ->
val month = firstMonth.plusMonths(page.toLong())
val firstWeek = startOfWeek(month.withDayOfMonth(1), mondayFirst)
Column(Modifier.fillMaxSize()) {
repeat(6) { w ->
val weekStart = firstWeek.plusWeeks(w.toLong())
WeekRow(
rowModifier = Modifier.fillMaxWidth().weight(1f),
weekStart = weekStart,
today = today,
weekEvents = eventsByWeek[weekStart] ?: emptyList(),
@@ -193,10 +231,41 @@ fun MonthView(
}
}
}
} else {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize().onSizeChanged { gridWidthPx = it.width },
) {
items(weekCount, key = { it }, contentType = { "week" }) { index ->
val weekStart = firstVisible.plusWeeks(index.toLong())
WeekRow(
rowModifier = Modifier.fillMaxWidth().height(ROW_HEIGHT),
weekStart = weekStart,
today = today,
weekEvents = eventsByWeek[weekStart] ?: emptyList(),
cellW = cellW,
dimPast = dimPast,
now = now,
lang = lang,
cwLabel = cwLabel,
dividerColor = dividerColor,
gridColor = gridColor,
labelColor = labelColor,
secondaryText = secondaryText,
todayColor = todayColor,
onDayClick = onDayClick,
onDayLongPress = onDayLongPress,
onEventClick = onEventClick,
)
}
}
}
}
}
@Composable
private fun WeekRow(
rowModifier: Modifier,
weekStart: LocalDate,
today: LocalDate,
weekEvents: List<CalEvent>,
@@ -220,7 +289,9 @@ private fun WeekRow(
val packed = remember(weekStart, weekEvents) { packEvents(weekStart, weekEvents) }
Box(Modifier.fillMaxWidth().height(ROW_HEIGHT)) {
// Scroll mode passes a fixed ROW_HEIGHT; paged mode passes weight(1f) so six
// rows fill the screen. Event bars anchor to the top; the day cells fill height.
Box(rowModifier) {
Row(Modifier.fillMaxSize()) {
days.forEachIndexed { idx, day ->
val edge = when {

View File

@@ -71,6 +71,7 @@ fun SettingsScreen(
val initialSettings = LocalAppSettings.current
var settings by remember { mutableStateOf(initialSettings) }
var cacheMonths by remember { mutableStateOf(vm.cacheMonths) }
var monthPaged by remember { mutableStateOf(vm.monthViewPaged) }
fun update(newSettings: AppSettings) {
settings = newSettings
@@ -126,6 +127,12 @@ fun SettingsScreen(
}
Spacer(Modifier.size(16.dp))
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
Text(tr("settings.month_paged"), style = MaterialTheme.typography.bodyLarge)
Switch(checked = monthPaged, onCheckedChange = { monthPaged = it; vm.monthViewPaged = it })
}
Spacer(Modifier.size(16.dp))
Divider(Modifier.padding(vertical = 16.dp))
Section(tr("settings.language"))

View File

@@ -32,6 +32,11 @@ class SettingsViewModel @Inject constructor(
get() = settingsStore.cacheMonths
set(value) { settingsStore.cacheMonths = value }
/** Device-local: month view as horizontal paged (swipe) vs. scroll feed. */
var monthViewPaged: Boolean
get() = settingsStore.monthViewPaged
set(value) { settingsStore.monthViewPaged = value }
// ---- Profile chapter (server-backed) ----
var displayName by mutableStateOf("")