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 calendarColor: String,
val source: 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 { 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. */ /** Parse one event object from the `/api/caldav/events` aggregate response. */
fun fromJson(json: JSONObject): CalEvent? { fun fromJson(json: JSONObject): CalEvent? {
val title = json.optString("title").takeIf { json.has("title") } ?: return null val title = json.optString("title").takeIf { json.has("title") } ?: return null
@@ -63,7 +79,7 @@ data class CalEvent(
color = colorRaw.takeIf { it.isNotBlank() }, color = colorRaw.takeIf { it.isNotBlank() },
calendarId = calendarId, calendarId = calendarId,
calendarName = json.optString("calendar_name", ""), calendarName = json.optString("calendar_name", ""),
calendarColor = json.optString("calendarColor", "#4285f4"), calendarColor = json.optString("calendarColor", ""),
source = json.optString("source", "local"), 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.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -72,15 +74,19 @@ fun CalendarScreen(
var editor by remember { mutableStateOf<EditorRequest?>(null) } var editor by remember { mutableStateOf<EditorRequest?>(null) }
var overlay by remember { mutableStateOf(Overlay.NONE) } 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( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { title = {
Text( Text(barTitle, maxLines = 1, overflow = TextOverflow.Ellipsis)
titleForView(state.viewType, state.currentDate, lang),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}, },
navigationIcon = { navigationIcon = {
IconButton(onClick = { showMenu = true }) { IconButton(onClick = { showMenu = true }) {
@@ -88,15 +94,19 @@ fun CalendarScreen(
} }
}, },
actions = { actions = {
if (!isMonth) {
IconButton(onClick = vm::navigatePrev) { IconButton(onClick = vm::navigatePrev) {
Icon(Icons.Filled.ChevronLeft, contentDescription = null) 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")) Icon(Icons.Filled.Today, contentDescription = tr("nav.today"))
} }
if (!isMonth) {
IconButton(onClick = vm::navigateNext) { IconButton(onClick = vm::navigateNext) {
Icon(Icons.Filled.ChevronRight, contentDescription = null) Icon(Icons.Filled.ChevronRight, contentDescription = null)
} }
}
IconButton(onClick = { showFilter = true }) { IconButton(onClick = { showFilter = true }) {
Icon(Icons.Filled.FilterList, contentDescription = tr("filter.button")) Icon(Icons.Filled.FilterList, contentDescription = tr("filter.button"))
} }
@@ -121,7 +131,11 @@ fun CalendarScreen(
) )
}, },
floatingActionButton = { 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")) Icon(Icons.Filled.Add, contentDescription = tr("cal.new_event"))
} }
}, },
@@ -137,9 +151,12 @@ fun CalendarScreen(
CalendarBody( CalendarBody(
state = state, state = state,
vm = vm, vm = vm,
monthListState = monthListState,
scrollToTodaySignal = todaySignal,
onVisibleMonthChange = { visibleMonth = it },
onEventClick = { detailEvent = it }, onEventClick = { detailEvent = it },
onDayClick = { date -> vm.goToDate(date, CalViewType.DAY) }, 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( private fun CalendarBody(
state: CalendarUiState, state: CalendarUiState,
vm: CalendarViewModel, vm: CalendarViewModel,
monthListState: androidx.compose.foundation.lazy.LazyListState,
scrollToTodaySignal: Int,
onVisibleMonthChange: (LocalDate) -> Unit,
onEventClick: (CalEvent) -> Unit, onEventClick: (CalEvent) -> Unit,
onDayClick: (LocalDate) -> Unit, onDayClick: (LocalDate) -> Unit,
onEmptySlotClick: (LocalDate) -> Unit, onDayLongPress: (LocalDate) -> Unit,
) { ) {
when (state.viewType) { 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.WEEK -> WeekView(state, vm, onEventClick)
CalViewType.DAY -> DayView(state, vm, onEventClick) CalViewType.DAY -> DayView(state, vm, onEventClick)
CalViewType.QUARTER -> QuarterView(state, onDayClick) 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() { private fun prefetchBackground() {
val months = settingsStore.cacheMonths val months = settingsStore.cacheMonths
val today = LocalDate.now().withDayOfMonth(1) val today = LocalDate.now().withDayOfMonth(1)

View File

@@ -1,7 +1,9 @@
package com.scarriffle.calendarr.ui.calendar package com.scarriffle.calendarr.ui.calendar
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row 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.height
import androidx.compose.foundation.layout.padding 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.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Divider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp 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.colorFromHex
import com.scarriffle.calendarr.util.contrastingTextColor import com.scarriffle.calendarr.util.contrastingTextColor
import java.time.LocalDate 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 @Composable
fun MonthView( fun MonthView(
state: CalendarUiState, state: CalendarUiState,
vm: CalendarViewModel, vm: CalendarViewModel,
listState: LazyListState,
scrollToTodaySignal: Int,
onVisibleMonthChange: (LocalDate) -> Unit,
onDayClick: (LocalDate) -> Unit, onDayClick: (LocalDate) -> Unit,
onDayLongPress: (LocalDate) -> Unit,
onEventClick: (CalEvent) -> Unit, onEventClick: (CalEvent) -> Unit,
) { ) {
val lang = LocalLang.current val lang = LocalLang.current
val mondayFirst = state.weekStartsOnMonday val mondayFirst = state.weekStartsOnMonday
val today = LocalDate.now() val today = LocalDate.now()
val firstOfMonth = state.currentDate.withDayOfMonth(1)
val firstVisible = startOfWeekFor(firstOfMonth, mondayFirst) val firstVisible = remember(mondayFirst) {
val weeks = (0 until 6).map { w -> (0 until 7).map { d -> firstVisible.plusDays((w * 7 + d).toLong()) } } 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()) { Column(Modifier.fillMaxSize()) {
// Header: CW + weekdays // Fixed weekday header
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp)) { Row(Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
Box(Modifier.width(28.dp))
weekdayLabels(mondayFirst, lang).forEach { label -> weekdayLabels(mondayFirst, lang).forEach { label ->
Text( Text(
label, label,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
textAlign = androidx.compose.ui.text.style.TextAlign.Center, textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
} }
weeks.forEach { week -> Divider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f))
Row(Modifier.fillMaxWidth().weight(1f)) {
val cw = week.first().get(IsoFields.WEEK_OF_WEEK_BASED_YEAR) LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
Box(Modifier.width(28.dp).fillMaxSize(), contentAlignment = Alignment.TopCenter) { items(weekCount) { index ->
Text( val weekStart = firstVisible.plusWeeks(index.toLong())
"$cw", WeekRow(
style = MaterialTheme.typography.labelSmall, weekStart = weekStart,
color = MaterialTheme.colorScheme.outline, today = today,
fontSize = 9.sp, events = state.events,
modifier = Modifier.padding(top = 4.dp), vm = vm,
) lang = lang,
} onDayClick = onDayClick,
week.forEach { day -> onDayLongPress = onDayLongPress,
DayCell(
day = day,
inMonth = day.month == firstOfMonth.month,
isToday = day == today,
events = vm.eventsOn(day, state.events),
onClick = { onDayClick(day) },
onEventClick = onEventClick, onEventClick = onEventClick,
modifier = Modifier.weight(1f).fillMaxSize(),
) )
} }
} }
} }
}
} }
@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 @Composable
private fun DayCell( private fun DayCell(
day: LocalDate, day: LocalDate,
inMonth: Boolean,
isToday: Boolean, isToday: Boolean,
events: List<CalEvent>, events: List<CalEvent>,
lang: String,
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: () -> Unit,
onEventClick: (CalEvent) -> Unit, onEventClick: (CalEvent) -> Unit,
modifier: Modifier = Modifier, 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( Column(
modifier = modifier modifier = modifier
.padding(1.dp) .combinedClickable(onClick = onClick, onLongClick = onLongClick)
.clickable(onClick = onClick), .padding(horizontal = 1.dp, vertical = 2.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.padding(top = 2.dp)) { 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) { if (isToday) {
Box( Box(
Modifier Modifier
.height(22.dp).width(22.dp) .height(18.dp)
.clip(RoundedCornerShape(11.dp)) .width(18.dp)
.clip(RoundedCornerShape(9.dp))
.background(todayColor), .background(todayColor),
) )
} }
Text( Text(
"${day.dayOfMonth}", "${day.dayOfMonth}",
style = MaterialTheme.typography.labelMedium, fontSize = 11.sp,
fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal, fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal,
color = when { color = if (isToday) todayColor.contrastingTextColor() else MaterialTheme.colorScheme.onBackground,
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) { if (events.size > 3) {
Text( Text("+${events.size - 3}", fontSize = 8.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
"+${events.size - 3}",
style = MaterialTheme.typography.labelSmall,
fontSize = 8.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} }
} }
} }
@@ -140,26 +222,26 @@ private fun DayCell(
@Composable @Composable
private fun EventChip(event: CalEvent, onClick: () -> Unit) { private fun EventChip(event: CalEvent, onClick: () -> Unit) {
val color = colorFromHex(event.effectiveColor) val color = colorFromHex(event.effectiveColor)
Box( Surface(
Modifier color = color,
shape = RoundedCornerShape(3.dp),
modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 1.dp, vertical = 1.dp) .padding(horizontal = 1.dp, vertical = 0.5.dp)
.clip(RoundedCornerShape(3.dp)) .clickable(onClick = onClick),
.background(color)
.clickable(onClick = onClick)
.padding(horizontal = 3.dp, vertical = 1.dp),
) { ) {
Text( Text(
event.title, event.title,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
fontSize = 9.sp, fontSize = 8.sp,
color = color.contrastingTextColor(), 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 dow = date.dayOfWeek.value
val offset = if (mondayFirst) dow - 1 else dow % 7 val offset = if (mondayFirst) dow - 1 else dow % 7
return date.minusDays(offset.toLong()) return date.minusDays(offset.toLong())

View File

@@ -25,12 +25,22 @@ fun CalendarrTheme(
) { ) {
val primary = BrandGreen val primary = BrandGreen
val container = Color(0xFF14532D)
val onContainer = Color(0xFFB7F0C6)
val colors = darkColorScheme( val colors = darkColorScheme(
primary = primary, primary = primary,
onPrimary = primary.contrastingTextColor(), onPrimary = primary.contrastingTextColor(),
primaryContainer = container,
onPrimaryContainer = onContainer,
secondary = primary, secondary = primary,
onSecondary = primary.contrastingTextColor(), onSecondary = primary.contrastingTextColor(),
secondaryContainer = container,
onSecondaryContainer = onContainer,
tertiary = primary, tertiary = primary,
onTertiary = primary.contrastingTextColor(),
tertiaryContainer = container,
onTertiaryContainer = onContainer,
surfaceTint = primary,
background = Color(0xFF000000), background = Color(0xFF000000),
onBackground = Color(0xFFF2F2F7), onBackground = Color(0xFFF2F2F7),
surface = Color(0xFF1C1C1E), 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]. */ /** Parse a "#RRGGBB" (or "RRGGBB") hex string into a Compose [Color]. */
fun colorFromHex(hex: String?, fallback: Color = Color(0xFF4285F4)): Color { fun colorFromHex(hex: String?, fallback: Color = Color(0xFF4285F4)): Color {
if (hex.isNullOrBlank()) return fallback if (hex.isNullOrBlank()) return fallback
val clean = hex.trim().removePrefix("#") val clean = hex.trim().removePrefix("#").filter { it.isLetterOrDigit() }
return when (clean.length) { 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 { 6 -> runCatching {
val v = clean.toLong(16) val v = clean.toLong(16)
Color( Color(