From c236db7fe9196fc0dc4b835c24ef85e08419ea7e Mon Sep 17 00:00:00 2001 From: Guido Schmit Date: Sun, 31 May 2026 13:08:54 +0200 Subject: [PATCH] feat: scrolling month calendar, green theme fixes, long-press create, event colors - Month view rewritten as a continuous vertical scroll (iOS-style): no prev/next buttons, compact fixed-height week rows, month label on the 1st, top-bar title follows the visible month, Today scrolls back to today - Long-press a day opens the event editor pre-filled with that date - Theme: green container/tint colors so the FAB and accents are no longer the Material default purple - Event colors: parse #RGB shorthand, and fall back to a stable per-calendar palette colour instead of a constant blue when the server omits a colour - Top bar hides prev/next arrows in month view (scroll instead) Co-Authored-By: Claude Opus 4.8 --- .../calendarr/domain/model/CalEvent.kt | 22 +- .../calendarr/ui/calendar/CalendarScreen.kt | 57 +++-- .../ui/calendar/CalendarViewModel.kt | 15 ++ .../calendarr/ui/calendar/MonthView.kt | 224 ++++++++++++------ .../scarriffle/calendarr/ui/theme/Theme.kt | 10 + .../scarriffle/calendarr/util/ColorUtil.kt | 9 +- 6 files changed, 248 insertions(+), 89 deletions(-) diff --git a/app/src/main/java/com/scarriffle/calendarr/domain/model/CalEvent.kt b/app/src/main/java/com/scarriffle/calendarr/domain/model/CalEvent.kt index a67ff6a..e4eaf34 100644 --- a/app/src/main/java/com/scarriffle/calendarr/domain/model/CalEvent.kt +++ b/app/src/main/java/com/scarriffle/calendarr/domain/model/CalEvent.kt @@ -23,10 +23,26 @@ data class CalEvent( val calendarColor: String, val source: String, ) { - /** Per-event override colour, falling back to the calendar's colour. */ - val effectiveColor: String get() = color?.takeIf { it.isNotBlank() } ?: calendarColor + /** + * Per-event override colour, then the calendar's colour, then a stable + * per-calendar palette colour (so events never collapse to one default). + */ + val effectiveColor: String + get() = color?.takeIf { it.isNotBlank() } + ?: calendarColor.takeIf { it.isNotBlank() } + ?: fallbackColorFor("$source:$calendarId") companion object { + private val FALLBACK_PALETTE = listOf( + "#34a853", "#4285f4", "#ea4335", "#fbbc05", + "#46bdc6", "#9c27b0", "#ff7043", "#7090c0", + ) + + private fun fallbackColorFor(key: String): String { + val idx = (key.hashCode().and(Int.MAX_VALUE)) % FALLBACK_PALETTE.size + return FALLBACK_PALETTE[idx] + } + /** Parse one event object from the `/api/caldav/events` aggregate response. */ fun fromJson(json: JSONObject): CalEvent? { val title = json.optString("title").takeIf { json.has("title") } ?: return null @@ -63,7 +79,7 @@ data class CalEvent( color = colorRaw.takeIf { it.isNotBlank() }, calendarId = calendarId, calendarName = json.optString("calendar_name", ""), - calendarColor = json.optString("calendarColor", "#4285f4"), + calendarColor = json.optString("calendarColor", ""), source = json.optString("source", "local"), ) } 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 f9025be..ded34dd 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 @@ -25,9 +25,11 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -72,15 +74,19 @@ fun CalendarScreen( var editor by remember { mutableStateOf(null) } var overlay by remember { mutableStateOf(Overlay.NONE) } + // Continuous month scrolling + val monthListState = rememberLazyListState() + var todaySignal by remember { mutableIntStateOf(0) } + var visibleMonth by remember { mutableStateOf(state.currentDate) } + val isMonth = state.viewType == CalViewType.MONTH + val barTitle = if (isMonth) titleForView(CalViewType.MONTH, visibleMonth, lang) + else titleForView(state.viewType, state.currentDate, lang) + Scaffold( topBar = { TopAppBar( title = { - Text( - titleForView(state.viewType, state.currentDate, lang), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Text(barTitle, maxLines = 1, overflow = TextOverflow.Ellipsis) }, navigationIcon = { IconButton(onClick = { showMenu = true }) { @@ -88,14 +94,18 @@ fun CalendarScreen( } }, actions = { - IconButton(onClick = vm::navigatePrev) { - Icon(Icons.Filled.ChevronLeft, contentDescription = null) + if (!isMonth) { + IconButton(onClick = vm::navigatePrev) { + Icon(Icons.Filled.ChevronLeft, contentDescription = null) + } } - IconButton(onClick = vm::moveToToday) { + IconButton(onClick = { if (isMonth) todaySignal++ else vm.moveToToday() }) { Icon(Icons.Filled.Today, contentDescription = tr("nav.today")) } - IconButton(onClick = vm::navigateNext) { - Icon(Icons.Filled.ChevronRight, contentDescription = null) + if (!isMonth) { + IconButton(onClick = vm::navigateNext) { + Icon(Icons.Filled.ChevronRight, contentDescription = null) + } } IconButton(onClick = { showFilter = true }) { Icon(Icons.Filled.FilterList, contentDescription = tr("filter.button")) @@ -121,7 +131,11 @@ fun CalendarScreen( ) }, floatingActionButton = { - FloatingActionButton(onClick = { editor = EditorRequest(null, state.currentDate) }) { + FloatingActionButton( + onClick = { editor = EditorRequest(null, if (isMonth) visibleMonth else state.currentDate) }, + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ) { Icon(Icons.Filled.Add, contentDescription = tr("cal.new_event")) } }, @@ -137,9 +151,12 @@ fun CalendarScreen( CalendarBody( state = state, vm = vm, + monthListState = monthListState, + scrollToTodaySignal = todaySignal, + onVisibleMonthChange = { visibleMonth = it }, onEventClick = { detailEvent = it }, onDayClick = { date -> vm.goToDate(date, CalViewType.DAY) }, - onEmptySlotClick = { date -> editor = EditorRequest(null, date) }, + onDayLongPress = { date -> editor = EditorRequest(null, date) }, ) } } @@ -215,12 +232,24 @@ fun CalendarScreen( private fun CalendarBody( state: CalendarUiState, vm: CalendarViewModel, + monthListState: androidx.compose.foundation.lazy.LazyListState, + scrollToTodaySignal: Int, + onVisibleMonthChange: (LocalDate) -> Unit, onEventClick: (CalEvent) -> Unit, onDayClick: (LocalDate) -> Unit, - onEmptySlotClick: (LocalDate) -> Unit, + onDayLongPress: (LocalDate) -> Unit, ) { when (state.viewType) { - CalViewType.MONTH -> MonthView(state, vm, onDayClick, onEventClick) + CalViewType.MONTH -> MonthView( + state = state, + vm = vm, + listState = monthListState, + scrollToTodaySignal = scrollToTodaySignal, + onVisibleMonthChange = onVisibleMonthChange, + onDayClick = onDayClick, + onDayLongPress = onDayLongPress, + onEventClick = onEventClick, + ) CalViewType.WEEK -> WeekView(state, vm, onEventClick) CalViewType.DAY -> DayView(state, vm, onEventClick) CalViewType.QUARTER -> QuarterView(state, onDayClick) 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 362ea93..0853c09 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 @@ -159,6 +159,21 @@ class CalendarViewModel @Inject constructor( } } + /** On-demand load for a month the user scrolled to in the continuous calendar. */ + fun ensureMonthLoaded(monthAnchor: LocalDate) { + val first = monthAnchor.withDayOfMonth(1) + val start = instant(first.minusMonths(1)) + val end = instant(first.plusMonths(2)) + if (isCached(start, end)) return + viewModelScope.launch { + _state.update { it.copy(isLoading = true) } + runCatching { repository.fetchEvents(start, end) } + .onSuccess { mergeIntoCache(it, start, end); refreshFromCache() } + .onFailure { e -> _state.update { it.copy(error = e.message) } } + _state.update { it.copy(isLoading = false) } + } + } + private fun prefetchBackground() { val months = settingsStore.cacheMonths val today = LocalDate.now().withDayOfMonth(1) 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 2690fa7..04a8a5a 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 @@ -1,7 +1,9 @@ package com.scarriffle.calendarr.ui.calendar +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -10,15 +12,23 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height 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.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Divider import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -28,111 +38,183 @@ import com.scarriffle.calendarr.ui.LocalLang import com.scarriffle.calendarr.util.colorFromHex import com.scarriffle.calendarr.util.contrastingTextColor import java.time.LocalDate -import java.time.temporal.IsoFields +import java.time.format.TextStyle +import java.time.temporal.ChronoUnit +private const val MONTHS_BACK = 18L +private const val MONTHS_AHEAD = 18L + +/** + * Continuous, vertically scrolling month calendar (matches the iOS app). + * There are no prev/next buttons — the user scrolls through weeks and the + * top-bar title follows the currently visible month. + */ @Composable fun MonthView( state: CalendarUiState, vm: CalendarViewModel, + listState: LazyListState, + scrollToTodaySignal: Int, + onVisibleMonthChange: (LocalDate) -> Unit, onDayClick: (LocalDate) -> Unit, + onDayLongPress: (LocalDate) -> Unit, onEventClick: (CalEvent) -> Unit, ) { val lang = LocalLang.current val mondayFirst = state.weekStartsOnMonday val today = LocalDate.now() - val firstOfMonth = state.currentDate.withDayOfMonth(1) - val firstVisible = startOfWeekFor(firstOfMonth, mondayFirst) - val weeks = (0 until 6).map { w -> (0 until 7).map { d -> firstVisible.plusDays((w * 7 + d).toLong()) } } + + val firstVisible = remember(mondayFirst) { + startOfWeek(today.withDayOfMonth(1).minusMonths(MONTHS_BACK), mondayFirst) + } + val end = remember(mondayFirst) { + startOfWeek(today.withDayOfMonth(1).plusMonths(MONTHS_AHEAD), mondayFirst) + } + val weekCount = remember(firstVisible, end) { + (ChronoUnit.WEEKS.between(firstVisible, end).toInt() + 1).coerceAtLeast(1) + } + val todayIndex = remember(firstVisible) { + ChronoUnit.WEEKS.between(firstVisible, startOfWeek(today, mondayFirst)).toInt() + } + + // Initial scroll to today's week. + LaunchedEffect(Unit) { + listState.scrollToItem((todayIndex - 1).coerceAtLeast(0)) + } + // "Today" button. + LaunchedEffect(scrollToTodaySignal) { + if (scrollToTodaySignal > 0) listState.animateScrollToItem((todayIndex - 1).coerceAtLeast(0)) + } + // Track visible month + trigger on-demand loads. + LaunchedEffect(listState, weekCount) { + snapshotFlow { listState.firstVisibleItemIndex }.collect { idx -> + val weekStart = firstVisible.plusWeeks(idx.toLong()) + val month = weekStart.plusDays(3) // mid-week → representative month + onVisibleMonthChange(month) + vm.ensureMonthLoaded(month) + } + } Column(Modifier.fillMaxSize()) { - // Header: CW + weekdays - Row(Modifier.fillMaxWidth().padding(vertical = 4.dp)) { - Box(Modifier.width(28.dp)) + // Fixed weekday header + Row(Modifier.fillMaxWidth().padding(vertical = 2.dp)) { weekdayLabels(mondayFirst, lang).forEach { label -> Text( label, modifier = Modifier.weight(1f), - textAlign = androidx.compose.ui.text.style.TextAlign.Center, + textAlign = TextAlign.Center, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } - weeks.forEach { week -> - Row(Modifier.fillMaxWidth().weight(1f)) { - val cw = week.first().get(IsoFields.WEEK_OF_WEEK_BASED_YEAR) - Box(Modifier.width(28.dp).fillMaxSize(), contentAlignment = Alignment.TopCenter) { - Text( - "$cw", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.outline, - fontSize = 9.sp, - modifier = Modifier.padding(top = 4.dp), - ) - } - week.forEach { day -> - DayCell( - day = day, - inMonth = day.month == firstOfMonth.month, - isToday = day == today, - events = vm.eventsOn(day, state.events), - onClick = { onDayClick(day) }, - onEventClick = onEventClick, - modifier = Modifier.weight(1f).fillMaxSize(), - ) - } + Divider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f)) + + LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) { + items(weekCount) { index -> + val weekStart = firstVisible.plusWeeks(index.toLong()) + WeekRow( + weekStart = weekStart, + today = today, + events = state.events, + vm = vm, + lang = lang, + onDayClick = onDayClick, + onDayLongPress = onDayLongPress, + onEventClick = onEventClick, + ) } } } } +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun WeekRow( + weekStart: LocalDate, + today: LocalDate, + events: List, + vm: CalendarViewModel, + lang: String, + onDayClick: (LocalDate) -> Unit, + onDayLongPress: (LocalDate) -> Unit, + onEventClick: (CalEvent) -> Unit, +) { + val days = (0 until 7).map { weekStart.plusDays(it.toLong()) } + Row( + Modifier + .fillMaxWidth() + .height(78.dp), + ) { + days.forEach { day -> + DayCell( + day = day, + isToday = day == today, + events = vm.eventsOn(day, events), + lang = lang, + onClick = { onDayClick(day) }, + onLongClick = { onDayLongPress(day) }, + onEventClick = onEventClick, + modifier = Modifier.weight(1f).fillMaxSize(), + ) + } + } + Divider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f)) +} + +@OptIn(ExperimentalFoundationApi::class) @Composable private fun DayCell( day: LocalDate, - inMonth: Boolean, isToday: Boolean, events: List, + lang: String, onClick: () -> Unit, + onLongClick: () -> Unit, onEventClick: (CalEvent) -> Unit, modifier: Modifier = Modifier, ) { - val todayColor = colorFromHex(LocalAppSettings.current.todayColor) + val settings = LocalAppSettings.current + val todayColor = colorFromHex(settings.todayColor) + val monthLabelColor = colorFromHex(settings.monthLabelColor, MaterialTheme.colorScheme.onSurfaceVariant) + val isFirst = day.dayOfMonth == 1 + Column( modifier = modifier - .padding(1.dp) - .clickable(onClick = onClick), + .combinedClickable(onClick = onClick, onLongClick = onLongClick) + .padding(horizontal = 1.dp, vertical = 2.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { - Box(contentAlignment = Alignment.Center, modifier = Modifier.padding(top = 2.dp)) { - if (isToday) { - Box( - Modifier - .height(22.dp).width(22.dp) - .clip(RoundedCornerShape(11.dp)) - .background(todayColor), + Row(verticalAlignment = Alignment.CenterVertically) { + if (isFirst) { + Text( + day.month.getDisplayName(TextStyle.SHORT, com.scarriffle.calendarr.ui.L10n.locale(lang)), + fontSize = 8.sp, + color = monthLabelColor, + modifier = Modifier.padding(end = 2.dp), + ) + } + Box(contentAlignment = Alignment.Center) { + if (isToday) { + Box( + Modifier + .height(18.dp) + .width(18.dp) + .clip(RoundedCornerShape(9.dp)) + .background(todayColor), + ) + } + Text( + "${day.dayOfMonth}", + fontSize = 11.sp, + fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal, + color = if (isToday) todayColor.contrastingTextColor() else MaterialTheme.colorScheme.onBackground, ) } - Text( - "${day.dayOfMonth}", - style = MaterialTheme.typography.labelMedium, - fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal, - color = when { - isToday -> todayColor.contrastingTextColor() - inMonth -> MaterialTheme.colorScheme.onBackground - else -> MaterialTheme.colorScheme.outline - }, - ) - } - events.take(3).forEach { ev -> - EventChip(ev, onClick = { onEventClick(ev) }) } + events.take(3).forEach { ev -> EventChip(ev) { onEventClick(ev) } } if (events.size > 3) { - Text( - "+${events.size - 3}", - style = MaterialTheme.typography.labelSmall, - fontSize = 8.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Text("+${events.size - 3}", fontSize = 8.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) } } } @@ -140,26 +222,26 @@ private fun DayCell( @Composable private fun EventChip(event: CalEvent, onClick: () -> Unit) { val color = colorFromHex(event.effectiveColor) - Box( - Modifier + Surface( + color = color, + shape = RoundedCornerShape(3.dp), + modifier = Modifier .fillMaxWidth() - .padding(horizontal = 1.dp, vertical = 1.dp) - .clip(RoundedCornerShape(3.dp)) - .background(color) - .clickable(onClick = onClick) - .padding(horizontal = 3.dp, vertical = 1.dp), + .padding(horizontal = 1.dp, vertical = 0.5.dp) + .clickable(onClick = onClick), ) { Text( event.title, maxLines = 1, overflow = TextOverflow.Ellipsis, - fontSize = 9.sp, + fontSize = 8.sp, color = color.contrastingTextColor(), + modifier = Modifier.padding(horizontal = 3.dp, vertical = 1.dp), ) } } -private fun startOfWeekFor(date: LocalDate, mondayFirst: Boolean): LocalDate { +private fun startOfWeek(date: LocalDate, mondayFirst: Boolean): LocalDate { val dow = date.dayOfWeek.value val offset = if (mondayFirst) dow - 1 else dow % 7 return date.minusDays(offset.toLong()) diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/theme/Theme.kt b/app/src/main/java/com/scarriffle/calendarr/ui/theme/Theme.kt index eb64f06..ff7da21 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/theme/Theme.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/theme/Theme.kt @@ -25,12 +25,22 @@ fun CalendarrTheme( ) { val primary = BrandGreen + val container = Color(0xFF14532D) + val onContainer = Color(0xFFB7F0C6) val colors = darkColorScheme( primary = primary, onPrimary = primary.contrastingTextColor(), + primaryContainer = container, + onPrimaryContainer = onContainer, secondary = primary, onSecondary = primary.contrastingTextColor(), + secondaryContainer = container, + onSecondaryContainer = onContainer, tertiary = primary, + onTertiary = primary.contrastingTextColor(), + tertiaryContainer = container, + onTertiaryContainer = onContainer, + surfaceTint = primary, background = Color(0xFF000000), onBackground = Color(0xFFF2F2F7), surface = Color(0xFF1C1C1E), diff --git a/app/src/main/java/com/scarriffle/calendarr/util/ColorUtil.kt b/app/src/main/java/com/scarriffle/calendarr/util/ColorUtil.kt index 3220474..1046116 100644 --- a/app/src/main/java/com/scarriffle/calendarr/util/ColorUtil.kt +++ b/app/src/main/java/com/scarriffle/calendarr/util/ColorUtil.kt @@ -5,8 +5,15 @@ import androidx.compose.ui.graphics.Color /** Parse a "#RRGGBB" (or "RRGGBB") hex string into a Compose [Color]. */ fun colorFromHex(hex: String?, fallback: Color = Color(0xFF4285F4)): Color { if (hex.isNullOrBlank()) return fallback - val clean = hex.trim().removePrefix("#") + val clean = hex.trim().removePrefix("#").filter { it.isLetterOrDigit() } return when (clean.length) { + 3 -> runCatching { + // #RGB shorthand -> expand each nibble + val r = clean[0].digitToInt(16) * 17 + val g = clean[1].digitToInt(16) * 17 + val b = clean[2].digitToInt(16) * 17 + Color(r / 255f, g / 255f, b / 255f, 1f) + }.getOrDefault(fallback) 6 -> runCatching { val v = clean.toLong(16) Color(