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 <noreply@anthropic.com>
This commit is contained in:
Guido Schmit
2026-05-31 13:08:54 +02:00
parent a1c36a8a03
commit c236db7fe9
6 changed files with 248 additions and 89 deletions

View File

@@ -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"),
)
}

View File

@@ -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<EditorRequest?>(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)

View File

@@ -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)

View File

@@ -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<CalEvent>,
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<CalEvent>,
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())

View File

@@ -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),

View File

@@ -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(