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) get() = prefs.getInt(K_CACHE_MONTHS, 3)
set(value) = prefs.edit().putInt(K_CACHE_MONTHS, value).apply() 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") --- // --- Hidden calendars ("source:id") ---
var hiddenCalendarKeys: Set<String> var hiddenCalendarKeys: Set<String>
@@ -99,6 +105,7 @@ class SettingsStore @Inject constructor(
const val K_DEFAULT_REMINDER = "default_reminder_minutes" const val K_DEFAULT_REMINDER = "default_reminder_minutes"
const val K_DEFAULT_DURATION = "default_event_duration_minutes" const val K_DEFAULT_DURATION = "default_event_duration_minutes"
const val K_CACHE_MONTHS = "cache_months" 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_HIDDEN = "hidden_calendar_keys"
const val K_BANISHED = "banished_calendar_keys" const val K_BANISHED = "banished_calendar_keys"
const val K_REMINDER_DISABLED = "reminder_disabled_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.calview" to "Kalenderansicht", "settings.defaultview" to "Standardansicht",
"settings.firstweekday" to "Erster Wochentag", "settings.monday" to "Montag", "settings.firstweekday" to "Erster Wochentag", "settings.monday" to "Montag",
"settings.sunday" to "Sonntag", "settings.dimpast" to "Vergangene Termine ausgrauen", "settings.sunday" to "Sonntag", "settings.dimpast" to "Vergangene Termine ausgrauen",
"settings.month_paged" to "Monatsansicht seitenweise wischen",
"settings.hourheight" to "Stundenhöhe", "settings.hourheight" to "Stundenhöhe",
"settings.hourheight.compact" to "Kompakt", "settings.hourheight.normal" to "Normal", "settings.hourheight.compact" to "Kompakt", "settings.hourheight.normal" to "Normal",
"settings.hourheight.comfort" to "Komfort", "settings.hourheight.large" to "Gross", "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.calview" to "Calendar view", "settings.defaultview" to "Default view",
"settings.firstweekday" to "First day of week", "settings.monday" to "Monday", "settings.firstweekday" to "First day of week", "settings.monday" to "Monday",
"settings.sunday" to "Sunday", "settings.dimpast" to "Dim past events", "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" to "Hour height",
"settings.hourheight.compact" to "Compact", "settings.hourheight.normal" to "Normal", "settings.hourheight.compact" to "Compact", "settings.hourheight.normal" to "Normal",
"settings.hourheight.comfort" to "Comfort", "settings.hourheight.large" to "Large", "settings.hourheight.comfort" to "Comfort", "settings.hourheight.large" to "Large",

View File

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

View File

@@ -58,6 +58,8 @@ data class CalendarUiState(
// Full calendar list across all sources (loaded on demand) so the filter // Full calendar list across all sources (loaded on demand) so the filter
// shows every calendar, including ones with no events in the loaded range. // shows every calendar, including ones with no events in the loaded range.
val allCalendars: List<CalendarFilterEntry> = emptyList(), 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" fun groupMemberKey(ownerId: Int): String = "gm:$ownerId"
@@ -122,9 +124,15 @@ class CalendarViewModel @Inject constructor(
hiddenKeys = settingsStore.hiddenCalendarKeys, hiddenKeys = settingsStore.hiddenCalendarKeys,
banishedKeys = settingsStore.banishedCalendarKeys, banishedKeys = settingsStore.banishedCalendarKeys,
reminderDisabledKeys = settingsStore.reminderDisabledCalendarKeys, 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. */ /** Default duration (minutes) for a new event's end time. */
val defaultEventDurationMinutes: Int get() = settingsStore.loadSettings().defaultEventDurationMinutes 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.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState 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.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -79,7 +81,10 @@ private data class PlacedBar(
private class WeekLayout(val bars: List<PlacedBar>, val overflowPerCol: IntArray) 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 @Composable
fun MonthView( fun MonthView(
state: CalendarUiState, state: CalendarUiState,
@@ -127,6 +132,39 @@ fun MonthView(
val todayIndex = remember(firstVisible) { weekIndexOf(today) } 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(Unit) { listState.scrollToItem((todayIndex - 1).coerceAtLeast(0)) }
LaunchedEffect(scrollToTodaySignal) { LaunchedEffect(scrollToTodaySignal) {
if (scrollToTodaySignal > 0) listState.animateScrollToItem((todayIndex - 1).coerceAtLeast(0)) if (scrollToTodaySignal > 0) listState.animateScrollToItem((todayIndex - 1).coerceAtLeast(0))
@@ -145,13 +183,7 @@ fun MonthView(
vm.ensureMonthLoaded(month) 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()) { Column(Modifier.fillMaxSize()) {
Row(Modifier.fillMaxWidth().padding(vertical = 3.dp)) { Row(Modifier.fillMaxWidth().padding(vertical = 3.dp)) {
@@ -166,13 +198,19 @@ fun MonthView(
} }
} }
LazyColumn( if (state.monthViewPaged) {
state = listState, // One month per page, six height-filling week rows, swipe to change month.
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize().onSizeChanged { gridWidthPx = it.width }, modifier = Modifier.fillMaxSize().onSizeChanged { gridWidthPx = it.width },
) { ) { page ->
items(weekCount, key = { it }, contentType = { "week" }) { index -> val month = firstMonth.plusMonths(page.toLong())
val weekStart = firstVisible.plusWeeks(index.toLong()) val firstWeek = startOfWeek(month.withDayOfMonth(1), mondayFirst)
Column(Modifier.fillMaxSize()) {
repeat(6) { w ->
val weekStart = firstWeek.plusWeeks(w.toLong())
WeekRow( WeekRow(
rowModifier = Modifier.fillMaxWidth().weight(1f),
weekStart = weekStart, weekStart = weekStart,
today = today, today = today,
weekEvents = eventsByWeek[weekStart] ?: emptyList(), 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 @Composable
private fun WeekRow( private fun WeekRow(
rowModifier: Modifier,
weekStart: LocalDate, weekStart: LocalDate,
today: LocalDate, today: LocalDate,
weekEvents: List<CalEvent>, weekEvents: List<CalEvent>,
@@ -220,7 +289,9 @@ private fun WeekRow(
val packed = remember(weekStart, weekEvents) { packEvents(weekStart, weekEvents) } 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()) { Row(Modifier.fillMaxSize()) {
days.forEachIndexed { idx, day -> days.forEachIndexed { idx, day ->
val edge = when { val edge = when {

View File

@@ -71,6 +71,7 @@ fun SettingsScreen(
val initialSettings = LocalAppSettings.current val initialSettings = LocalAppSettings.current
var settings by remember { mutableStateOf(initialSettings) } var settings by remember { mutableStateOf(initialSettings) }
var cacheMonths by remember { mutableStateOf(vm.cacheMonths) } var cacheMonths by remember { mutableStateOf(vm.cacheMonths) }
var monthPaged by remember { mutableStateOf(vm.monthViewPaged) }
fun update(newSettings: AppSettings) { fun update(newSettings: AppSettings) {
settings = newSettings settings = newSettings
@@ -126,6 +127,12 @@ fun SettingsScreen(
} }
Spacer(Modifier.size(16.dp)) 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)) Divider(Modifier.padding(vertical = 16.dp))
Section(tr("settings.language")) Section(tr("settings.language"))

View File

@@ -32,6 +32,11 @@ class SettingsViewModel @Inject constructor(
get() = settingsStore.cacheMonths get() = settingsStore.cacheMonths
set(value) { settingsStore.cacheMonths = value } 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) ---- // ---- Profile chapter (server-backed) ----
var displayName by mutableStateOf("") var displayName by mutableStateOf("")