diff --git a/app/src/main/java/com/scarriffle/calendarr/data/SettingsStore.kt b/app/src/main/java/com/scarriffle/calendarr/data/SettingsStore.kt index d20f257..b74bff7 100644 --- a/app/src/main/java/com/scarriffle/calendarr/data/SettingsStore.kt +++ b/app/src/main/java/com/scarriffle/calendarr/data/SettingsStore.kt @@ -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 @@ -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" diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt b/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt index 2374e82..2cf4f81 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt @@ -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", diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt index dd79b8f..8e5b58c 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt @@ -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, ) diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt index 15b5ea7..91c217c 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt @@ -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 = 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 diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt index 9632d53..202eb4d 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt @@ -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, 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,24 +132,13 @@ fun MonthView( val todayIndex = remember(firstVisible) { weekIndexOf(today) } - LaunchedEffect(Unit) { listState.scrollToItem((todayIndex - 1).coerceAtLeast(0)) } - LaunchedEffect(scrollToTodaySignal) { - if (scrollToTodaySignal > 0) listState.animateScrollToItem((todayIndex - 1).coerceAtLeast(0)) - } - LaunchedEffect(monthJumpSignal) { - if (monthJumpSignal > 0 && monthJumpTarget != null) { - listState.animateScrollToItem(weekIndexOf(monthJumpTarget.withDayOfMonth(1))) - } - } - LaunchedEffect(listState, weekCount) { - snapshotFlow { listState.firstVisibleItemIndex } - .map { firstVisible.plusWeeks(it.toLong()).plusDays(3).withDayOfMonth(1) } - .distinctUntilChanged() - .collect { month -> - onVisibleMonthChange(month) - vm.ensureMonthLoaded(month) - } - } + // 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 @@ -153,6 +147,44 @@ fun MonthView( // 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)) + } + LaunchedEffect(monthJumpSignal) { + if (monthJumpSignal > 0 && monthJumpTarget != null) { + listState.animateScrollToItem(weekIndexOf(monthJumpTarget.withDayOfMonth(1))) + } + } + LaunchedEffect(listState, weekCount) { + snapshotFlow { listState.firstVisibleItemIndex } + .map { firstVisible.plusWeeks(it.toLong()).plusDays(3).withDayOfMonth(1) } + .distinctUntilChanged() + .collect { month -> + onVisibleMonthChange(month) + vm.ensureMonthLoaded(month) + } + } + } + Column(Modifier.fillMaxSize()) { Row(Modifier.fillMaxWidth().padding(vertical = 3.dp)) { weekdayLabels(mondayFirst, lang).forEach { label -> @@ -166,30 +198,66 @@ fun MonthView( } } - LazyColumn( - state = listState, - modifier = Modifier.fillMaxSize().onSizeChanged { gridWidthPx = it.width }, - ) { - items(weekCount, key = { it }, contentType = { "week" }) { index -> - val weekStart = firstVisible.plusWeeks(index.toLong()) - WeekRow( - 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, - ) + 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( + 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, + ) + } } } } @@ -197,6 +265,7 @@ fun MonthView( @Composable private fun WeekRow( + rowModifier: Modifier, weekStart: LocalDate, today: LocalDate, weekEvents: List, @@ -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 { diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsScreen.kt b/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsScreen.kt index f004359..0bb5d37 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsScreen.kt @@ -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")) diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsViewModel.kt index 159cf40..b4878ad 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsViewModel.kt @@ -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("")