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:
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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("")
|
||||
|
||||
Reference in New Issue
Block a user