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)
|
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"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,6 +198,40 @@ fun MonthView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 },
|
||||||
|
) { 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(),
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
modifier = Modifier.fillMaxSize().onSizeChanged { gridWidthPx = it.width },
|
modifier = Modifier.fillMaxSize().onSizeChanged { gridWidthPx = it.width },
|
||||||
@@ -173,6 +239,7 @@ fun MonthView(
|
|||||||
items(weekCount, key = { it }, contentType = { "week" }) { index ->
|
items(weekCount, key = { it }, contentType = { "week" }) { index ->
|
||||||
val weekStart = firstVisible.plusWeeks(index.toLong())
|
val weekStart = firstVisible.plusWeeks(index.toLong())
|
||||||
WeekRow(
|
WeekRow(
|
||||||
|
rowModifier = Modifier.fillMaxWidth().height(ROW_HEIGHT),
|
||||||
weekStart = weekStart,
|
weekStart = weekStart,
|
||||||
today = today,
|
today = today,
|
||||||
weekEvents = eventsByWeek[weekStart] ?: emptyList(),
|
weekEvents = eventsByWeek[weekStart] ?: emptyList(),
|
||||||
@@ -194,9 +261,11 @@ fun MonthView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@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 {
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
@@ -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("")
|
||||||
|
|||||||
Reference in New Issue
Block a user