fix: event colors/null parsing, edit+copy, settings chips, scroll perf, splash

- CalEvent parsing: handle Android's JSONObject.optString JSON-null quirk that
  returned the string "null" — this made every event blue (color "null" →
  fallback) and showed "null" for empty location/description. Now null-safe.
- Event detail: copy-to-calendar action; calendar row uses a calendar icon
  (was identical to the notes icon); empty location/notes rows hidden
- Event editor: copy mode (prefill from an existing event, save as new)
- Settings: hour height, text/line contrast are now button chips (like the
  cache range); color picker wraps, shows the current colour and a check mark
- Month scroll perf: precompute a day→events index once per change and only
  reload when the visible month actually changes (was filtering every cell
  every frame → laggy)
- Splash: dark window/splash background + inset launcher icon (was an ugly
  white box on startup)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Guido Schmit
2026-05-31 13:27:03 +02:00
parent c236db7fe9
commit 608580fc7e
10 changed files with 181 additions and 66 deletions

View File

@@ -43,11 +43,22 @@ data class CalEvent(
return FALLBACK_PALETTE[idx]
}
/**
* Read a string field, treating JSON null correctly. Android's
* [JSONObject.optString] returns the literal string "null" for a JSON
* null value, which previously made every event blue (color "null" →
* unparseable → fallback) and showed "null" for empty location/notes.
*/
private fun JSONObject.strOrNull(key: String): String? {
if (!has(key) || isNull(key)) return null
return optString(key, "").takeIf { it.isNotBlank() && it != "null" }
}
/** 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
val startStr = json.optString("start").takeIf { json.has("start") } ?: return null
val endStr = json.optString("end").takeIf { json.has("end") } ?: return null
val title = json.strOrNull("title") ?: return null
val startStr = json.strOrNull("start") ?: return null
val endStr = json.strOrNull("end") ?: return null
// id may be a String (local UUID) or an Int (CalDAV numeric)
val id: String = when (val raw = json.opt("id")) {
@@ -66,21 +77,20 @@ data class CalEvent(
else -> raw.toString()
}
val colorRaw = json.optString("color", "")
return CalEvent(
id = id,
url = json.optString("url", ""),
url = json.strOrNull("url") ?: "",
title = title,
startDate = start,
endDate = end,
isAllDay = isAllDay,
location = json.optString("location", ""),
notes = json.optString("description", ""),
color = colorRaw.takeIf { it.isNotBlank() },
location = json.strOrNull("location") ?: "",
notes = json.strOrNull("description") ?: "",
color = json.strOrNull("color"),
calendarId = calendarId,
calendarName = json.optString("calendar_name", ""),
calendarColor = json.optString("calendarColor", ""),
source = json.optString("source", "local"),
calendarName = json.strOrNull("calendar_name") ?: "",
calendarColor = json.strOrNull("calendarColor") ?: "",
source = json.strOrNull("source") ?: "local",
)
}
}

View File

@@ -53,10 +53,16 @@ object L10n {
"settings.color.accent" to "Akzentfarbe", "settings.color.today" to "Heutige-Tag-Farbe",
"settings.color.divider" to "Monatswechsel-Linie", "settings.color.label" to "Monatskürzel",
"settings.textcontrast" to "Schriftkontrast", "settings.linecontrast" to "Linienkontrast",
"settings.contrast.dark" to "Dunkel", "settings.contrast.medium" to "Mittel",
"settings.contrast.bright" to "Hell", "settings.contrast.max" to "Maximum",
"settings.linecontrast.barely" to "Kaum", "settings.linecontrast.subtle" to "Subtil",
"settings.linecontrast.normal" to "Normal", "settings.linecontrast.strong" to "Stark",
"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.hourheight" to "Stundenhöhe",
"settings.hourheight.compact" to "Kompakt", "settings.hourheight.normal" to "Normal",
"settings.hourheight.comfort" to "Komfort", "settings.hourheight.large" to "Gross",
"common.cancel" to "Abbrechen", "common.close" to "Schliessen",
"common.ok" to "OK", "common.error" to "Fehler", "common.delete" to "Löschen",
"common.save" to "Sichern", "common.retry" to "Erneut versuchen",
@@ -88,6 +94,7 @@ object L10n {
"event.calendar_picker" to "Kalender", "event.color_section" to "Farbe",
"event.color" to "Terminfarbe", "event.reset_color" to "Zurücksetzen",
"event.edit_title" to "Termin bearbeiten", "event.new_title" to "Neuer Termin",
"event.copy_title" to "Termin kopieren", "event.copy_to" to "In Kalender kopieren",
"event.save" to "Sichern", "event.add" to "Hinzufügen",
"event.delete_confirm" to "Diesen Termin löschen?",
"accounts.title" to "Konten", "accounts.loading" to "Lade Konten…",
@@ -146,10 +153,16 @@ object L10n {
"settings.color.accent" to "Accent color", "settings.color.today" to "Today color",
"settings.color.divider" to "Month divider line", "settings.color.label" to "Month abbreviation",
"settings.textcontrast" to "Text contrast", "settings.linecontrast" to "Line contrast",
"settings.contrast.dark" to "Dark", "settings.contrast.medium" to "Medium",
"settings.contrast.bright" to "Bright", "settings.contrast.max" to "Maximum",
"settings.linecontrast.barely" to "Barely", "settings.linecontrast.subtle" to "Subtle",
"settings.linecontrast.normal" to "Normal", "settings.linecontrast.strong" to "Strong",
"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.hourheight" to "Hour height",
"settings.hourheight.compact" to "Compact", "settings.hourheight.normal" to "Normal",
"settings.hourheight.comfort" to "Comfort", "settings.hourheight.large" to "Large",
"common.cancel" to "Cancel", "common.close" to "Close",
"common.ok" to "OK", "common.error" to "Error", "common.delete" to "Delete",
"common.save" to "Save", "common.retry" to "Retry",
@@ -181,6 +194,7 @@ object L10n {
"event.calendar_picker" to "Calendar", "event.color_section" to "Color",
"event.color" to "Event color", "event.reset_color" to "Reset",
"event.edit_title" to "Edit event", "event.new_title" to "New event",
"event.copy_title" to "Copy event", "event.copy_to" to "Copy to calendar",
"event.save" to "Save", "event.add" to "Add",
"event.delete_confirm" to "Delete this event?",
"accounts.title" to "Accounts", "accounts.loading" to "Loading accounts…",

View File

@@ -53,7 +53,7 @@ import java.time.LocalDate
private enum class Overlay { NONE, PROFILE, SETTINGS, ACCOUNTS }
data class EditorRequest(val existing: CalEvent?, val date: LocalDate)
data class EditorRequest(val existing: CalEvent?, val date: LocalDate, val prefill: CalEvent? = null)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -197,6 +197,10 @@ fun CalendarScreen(
vm.deleteEvent(ev) {}
detailEvent = null
},
onCopy = {
detailEvent = null
editor = EditorRequest(existing = null, date = localDate(ev.startDate), prefill = ev)
},
)
}

View File

@@ -40,6 +40,8 @@ import com.scarriffle.calendarr.util.contrastingTextColor
import java.time.LocalDate
import java.time.format.TextStyle
import java.time.temporal.ChronoUnit
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
private const val MONTHS_BACK = 18L
private const val MONTHS_AHEAD = 18L
@@ -85,16 +87,21 @@ fun MonthView(
LaunchedEffect(scrollToTodaySignal) {
if (scrollToTodaySignal > 0) listState.animateScrollToItem((todayIndex - 1).coerceAtLeast(0))
}
// Track visible month + trigger on-demand loads.
// Track visible month + trigger on-demand loads (only when the month changes).
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)
}
snapshotFlow { listState.firstVisibleItemIndex }
.map { firstVisible.plusWeeks(it.toLong()).plusDays(3).withDayOfMonth(1) }
.distinctUntilChanged()
.collect { month ->
onVisibleMonthChange(month)
vm.ensureMonthLoaded(month)
}
}
// Precompute a day → events index once per event-list change (avoids
// filtering the whole list for every cell on every recomposition → smooth scroll).
val eventsByDay = remember(state.events) { buildEventsByDay(state.events) }
Column(Modifier.fillMaxSize()) {
// Fixed weekday header
Row(Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
@@ -116,8 +123,7 @@ fun MonthView(
WeekRow(
weekStart = weekStart,
today = today,
events = state.events,
vm = vm,
eventsByDay = eventsByDay,
lang = lang,
onDayClick = onDayClick,
onDayLongPress = onDayLongPress,
@@ -133,8 +139,7 @@ fun MonthView(
private fun WeekRow(
weekStart: LocalDate,
today: LocalDate,
events: List<CalEvent>,
vm: CalendarViewModel,
eventsByDay: Map<LocalDate, List<CalEvent>>,
lang: String,
onDayClick: (LocalDate) -> Unit,
onDayLongPress: (LocalDate) -> Unit,
@@ -150,7 +155,7 @@ private fun WeekRow(
DayCell(
day = day,
isToday = day == today,
events = vm.eventsOn(day, events),
events = eventsByDay[day] ?: emptyList(),
lang = lang,
onClick = { onDayClick(day) },
onLongClick = { onDayLongPress(day) },
@@ -246,3 +251,22 @@ private fun startOfWeek(date: LocalDate, mondayFirst: Boolean): LocalDate {
val offset = if (mondayFirst) dow - 1 else dow % 7
return date.minusDays(offset.toLong())
}
/** Index events by each local day they overlap, for O(1) lookup while scrolling. */
private fun buildEventsByDay(events: List<CalEvent>): Map<LocalDate, List<CalEvent>> {
val map = HashMap<LocalDate, MutableList<CalEvent>>()
for (ev in events) {
val first = localDate(ev.startDate)
// end is exclusive; the last covered day is the instant just before it
val last = localDate(ev.endDate.minusSeconds(1)).coerceAtLeast(first)
var d = first
var guard = 0
while (!d.isAfter(last) && guard < 400) {
map.getOrPut(d) { mutableListOf() }.add(ev)
d = d.plusDays(1)
guard++
}
}
map.values.forEach { list -> list.sortBy { it.startDate } }
return map
}

View File

@@ -5,12 +5,16 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.LocationOn
@@ -41,13 +45,14 @@ import com.scarriffle.calendarr.ui.calendar.eventDateRange
import com.scarriffle.calendarr.ui.tr
import com.scarriffle.calendarr.util.colorFromHex
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun EventDetailSheet(
event: CalEvent,
onDismiss: () -> Unit,
onEdit: () -> Unit,
onDelete: () -> Unit,
onCopy: () -> Unit,
) {
val lang = LocalLang.current
var confirmDelete by remember { mutableStateOf(false) }
@@ -68,7 +73,7 @@ fun EventDetailSheet(
Spacer(Modifier.size(16.dp))
DetailRow(Icons.Filled.Schedule, eventDateRange(event, lang))
if (event.calendarName.isNotBlank()) {
DetailRow(Icons.Filled.Notes, event.calendarName)
DetailRow(Icons.Filled.CalendarMonth, event.calendarName)
}
if (event.location.isNotBlank()) {
DetailRow(Icons.Filled.LocationOn, event.location)
@@ -77,7 +82,7 @@ fun EventDetailSheet(
DetailRow(Icons.Filled.Notes, event.notes)
}
Spacer(Modifier.size(20.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
FlowRow(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
if (canEdit) {
OutlinedButton(onClick = onEdit) {
Icon(Icons.Filled.Edit, contentDescription = null)
@@ -85,6 +90,11 @@ fun EventDetailSheet(
Text(tr("event.edit_title"))
}
}
OutlinedButton(onClick = onCopy) {
Icon(Icons.Filled.ContentCopy, contentDescription = null)
Spacer(Modifier.size(6.dp))
Text(tr("event.copy_title"))
}
if (canDelete) {
OutlinedButton(onClick = { confirmDelete = true }) {
Icon(Icons.Filled.Delete, contentDescription = null, tint = MaterialTheme.colorScheme.error)

View File

@@ -75,16 +75,19 @@ fun EventEditorSheet(
val context = LocalContext.current
val lang = LocalLang.current
val existing = request.existing
// Fields are prefilled from the event being edited OR copied.
val template = existing ?: request.prefill
val isCopy = existing == null && request.prefill != null
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var title by remember { mutableStateOf(existing?.title ?: "") }
var allDay by remember { mutableStateOf(existing?.isAllDay ?: false) }
var location by remember { mutableStateOf(existing?.location ?: "") }
var description by remember { mutableStateOf(existing?.notes ?: "") }
var color by remember { mutableStateOf(existing?.color) }
var title by remember { mutableStateOf(template?.title ?: "") }
var allDay by remember { mutableStateOf(template?.isAllDay ?: false) }
var location by remember { mutableStateOf(template?.location ?: "") }
var description by remember { mutableStateOf(template?.notes ?: "") }
var color by remember { mutableStateOf(template?.color) }
val initialStart = existing?.startDate
val initialEnd = existing?.endDate
val initialStart = template?.startDate
val initialEnd = template?.endDate
var startDate by remember {
mutableStateOf(initialStart?.let { LocalDate.ofInstant(it, zone) } ?: request.date)
}
@@ -95,7 +98,7 @@ fun EventEditorSheet(
mutableStateOf(
initialEnd?.let {
val d = LocalDate.ofInstant(it, zone)
if (existing?.isAllDay == true) d.minusDays(1) else d
if (template?.isAllDay == true) d.minusDays(1) else d
} ?: request.date
)
}
@@ -103,7 +106,7 @@ fun EventEditorSheet(
mutableStateOf(initialEnd?.let { LocalTime.ofInstant(it, zone).withSecond(0).withNano(0) } ?: LocalTime.of(10, 0))
}
val preselected = existing?.let { ev ->
val preselected = template?.let { ev ->
val id = calendarKey(ev.source, ev.calendarId).substringAfter(":").toIntOrNull()
writableCalendars.firstOrNull { it.source == ev.source && it.numericId == id }
}
@@ -139,7 +142,11 @@ fun EventEditorSheet(
.padding(bottom = 32.dp),
) {
Text(
if (existing == null) tr("event.new_title") else tr("event.edit_title"),
when {
existing != null -> tr("event.edit_title")
isCopy -> tr("event.copy_title")
else -> tr("event.new_title")
},
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(vertical = 8.dp),

View File

@@ -7,6 +7,8 @@ import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -17,6 +19,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Divider
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -25,7 +28,6 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
@@ -38,7 +40,6 @@ import androidx.compose.runtime.setValue
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.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
@@ -47,6 +48,7 @@ import com.scarriffle.calendarr.domain.model.CalViewType
import com.scarriffle.calendarr.ui.LocalAppSettings
import com.scarriffle.calendarr.ui.tr
import com.scarriffle.calendarr.util.colorFromHex
import com.scarriffle.calendarr.util.contrastingTextColor
private val PALETTE = listOf(
"#4285f4", "#ea4335", "#34a853", "#fbbc05", "#46bdc6", "#9c27b0", "#ff7043", "#7090c0",
@@ -120,19 +122,37 @@ fun SettingsScreen(
Divider(Modifier.padding(vertical = 16.dp))
Section(tr("settings.hourheight"))
Text("${settings.hourHeight} dp", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Slider(
value = settings.hourHeight.toFloat(),
onValueChange = { settings = settings.copy(hourHeight = it.toInt()) },
onValueChangeFinished = { update(settings) },
valueRange = 28f..100f,
ChipRow(
options = listOf(
"28" to tr("settings.hourheight.compact"),
"44" to tr("settings.hourheight.normal"),
"60" to tr("settings.hourheight.comfort"),
"80" to tr("settings.hourheight.large"),
),
selected = settings.hourHeight.toString(),
onSelect = { update(settings.copy(hourHeight = it.toInt())) },
)
Spacer(Modifier.size(8.dp))
Spacer(Modifier.size(16.dp))
Section(tr("settings.textcontrast"))
ContrastStepper(settings.textContrast) { update(settings.copy(textContrast = it)) }
ChipRow(
options = listOf(
"1" to tr("settings.contrast.dark"), "2" to tr("settings.contrast.medium"),
"3" to tr("settings.contrast.bright"), "4" to tr("settings.contrast.max"),
),
selected = settings.textContrast.toString(),
onSelect = { update(settings.copy(textContrast = it.toInt())) },
)
Spacer(Modifier.size(16.dp))
Section(tr("settings.linecontrast"))
ContrastStepper(settings.lineContrast) { update(settings.copy(lineContrast = it)) }
ChipRow(
options = listOf(
"1" to tr("settings.linecontrast.barely"), "2" to tr("settings.linecontrast.subtle"),
"3" to tr("settings.linecontrast.normal"), "4" to tr("settings.linecontrast.strong"),
),
selected = settings.lineContrast.toString(),
onSelect = { update(settings.copy(lineContrast = it.toInt())) },
)
Divider(Modifier.padding(vertical = 16.dp))
Section(tr("settings.cache.title"))
@@ -162,29 +182,35 @@ private fun ChipRow(options: List<Pair<String, String>>, selected: String, onSel
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun ColorChooser(label: String, current: String, onPick: (String) -> Unit) {
Column(Modifier.padding(vertical = 6.dp)) {
Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Row(Modifier.padding(top = 4.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
PALETTE.forEach { hex ->
// Show the user's current colour even if it isn't one of the presets.
val swatches = remember(current) {
(if (PALETTE.any { it.equals(current, ignoreCase = true) }) PALETTE else listOf(current) + PALETTE)
}
Column(Modifier.fillMaxWidth().padding(vertical = 8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(18.dp).clip(CircleShape).background(colorFromHex(current)))
Spacer(Modifier.size(8.dp))
Text(label, style = MaterialTheme.typography.bodyMedium)
}
FlowRow(
Modifier.fillMaxWidth().padding(top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
swatches.forEach { hex ->
val selected = hex.equals(current, ignoreCase = true)
Box(
Modifier.size(28.dp).clip(CircleShape).background(colorFromHex(hex))
.then(if (selected) Modifier.border(2.dp, Color.White, CircleShape) else Modifier)
Modifier.size(34.dp).clip(CircleShape).background(colorFromHex(hex))
.then(if (selected) Modifier.border(3.dp, MaterialTheme.colorScheme.onBackground, CircleShape) else Modifier)
.clickable { onPick(hex) },
)
contentAlignment = Alignment.Center,
) {
if (selected) Icon(Icons.Filled.Check, contentDescription = null, tint = colorFromHex(hex).contrastingTextColor(), modifier = Modifier.size(18.dp))
}
}
}
}
}
@Composable
private fun ContrastStepper(value: Int, onChange: (Int) -> Unit) {
Slider(
value = value.toFloat(),
onValueChange = { onChange(it.toInt().coerceIn(1, 4)) },
valueRange = 1f..4f,
steps = 2,
)
}