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] 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. */ /** 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.strOrNull("title") ?: return null
val startStr = json.optString("start").takeIf { json.has("start") } ?: return null val startStr = json.strOrNull("start") ?: return null
val endStr = json.optString("end").takeIf { json.has("end") } ?: return null val endStr = json.strOrNull("end") ?: return null
// id may be a String (local UUID) or an Int (CalDAV numeric) // id may be a String (local UUID) or an Int (CalDAV numeric)
val id: String = when (val raw = json.opt("id")) { val id: String = when (val raw = json.opt("id")) {
@@ -66,21 +77,20 @@ data class CalEvent(
else -> raw.toString() else -> raw.toString()
} }
val colorRaw = json.optString("color", "")
return CalEvent( return CalEvent(
id = id, id = id,
url = json.optString("url", ""), url = json.strOrNull("url") ?: "",
title = title, title = title,
startDate = start, startDate = start,
endDate = end, endDate = end,
isAllDay = isAllDay, isAllDay = isAllDay,
location = json.optString("location", ""), location = json.strOrNull("location") ?: "",
notes = json.optString("description", ""), notes = json.strOrNull("description") ?: "",
color = colorRaw.takeIf { it.isNotBlank() }, color = json.strOrNull("color"),
calendarId = calendarId, calendarId = calendarId,
calendarName = json.optString("calendar_name", ""), calendarName = json.strOrNull("calendar_name") ?: "",
calendarColor = json.optString("calendarColor", ""), calendarColor = json.strOrNull("calendarColor") ?: "",
source = json.optString("source", "local"), 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.accent" to "Akzentfarbe", "settings.color.today" to "Heutige-Tag-Farbe",
"settings.color.divider" to "Monatswechsel-Linie", "settings.color.label" to "Monatskürzel", "settings.color.divider" to "Monatswechsel-Linie", "settings.color.label" to "Monatskürzel",
"settings.textcontrast" to "Schriftkontrast", "settings.linecontrast" to "Linienkontrast", "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.calview" to "Kalenderansicht", "settings.defaultview" to "Standardansicht",
"settings.firstweekday" to "Erster Wochentag", "settings.monday" to "Montag", "settings.firstweekday" to "Erster Wochentag", "settings.monday" to "Montag",
"settings.sunday" to "Sonntag", "settings.dimpast" to "Vergangene Termine ausgrauen", "settings.sunday" to "Sonntag", "settings.dimpast" to "Vergangene Termine ausgrauen",
"settings.hourheight" to "Stundenhöhe", "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.cancel" to "Abbrechen", "common.close" to "Schliessen",
"common.ok" to "OK", "common.error" to "Fehler", "common.delete" to "Löschen", "common.ok" to "OK", "common.error" to "Fehler", "common.delete" to "Löschen",
"common.save" to "Sichern", "common.retry" to "Erneut versuchen", "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.calendar_picker" to "Kalender", "event.color_section" to "Farbe",
"event.color" to "Terminfarbe", "event.reset_color" to "Zurücksetzen", "event.color" to "Terminfarbe", "event.reset_color" to "Zurücksetzen",
"event.edit_title" to "Termin bearbeiten", "event.new_title" to "Neuer Termin", "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.save" to "Sichern", "event.add" to "Hinzufügen",
"event.delete_confirm" to "Diesen Termin löschen?", "event.delete_confirm" to "Diesen Termin löschen?",
"accounts.title" to "Konten", "accounts.loading" to "Lade Konten…", "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.accent" to "Accent color", "settings.color.today" to "Today color",
"settings.color.divider" to "Month divider line", "settings.color.label" to "Month abbreviation", "settings.color.divider" to "Month divider line", "settings.color.label" to "Month abbreviation",
"settings.textcontrast" to "Text contrast", "settings.linecontrast" to "Line contrast", "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.calview" to "Calendar view", "settings.defaultview" to "Default view",
"settings.firstweekday" to "First day of week", "settings.monday" to "Monday", "settings.firstweekday" to "First day of week", "settings.monday" to "Monday",
"settings.sunday" to "Sunday", "settings.dimpast" to "Dim past events", "settings.sunday" to "Sunday", "settings.dimpast" to "Dim past events",
"settings.hourheight" to "Hour height", "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.cancel" to "Cancel", "common.close" to "Close",
"common.ok" to "OK", "common.error" to "Error", "common.delete" to "Delete", "common.ok" to "OK", "common.error" to "Error", "common.delete" to "Delete",
"common.save" to "Save", "common.retry" to "Retry", "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.calendar_picker" to "Calendar", "event.color_section" to "Color",
"event.color" to "Event color", "event.reset_color" to "Reset", "event.color" to "Event color", "event.reset_color" to "Reset",
"event.edit_title" to "Edit event", "event.new_title" to "New event", "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.save" to "Save", "event.add" to "Add",
"event.delete_confirm" to "Delete this event?", "event.delete_confirm" to "Delete this event?",
"accounts.title" to "Accounts", "accounts.loading" to "Loading accounts…", "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 } 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -197,6 +197,10 @@ fun CalendarScreen(
vm.deleteEvent(ev) {} vm.deleteEvent(ev) {}
detailEvent = null 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.LocalDate
import java.time.format.TextStyle import java.time.format.TextStyle
import java.time.temporal.ChronoUnit 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_BACK = 18L
private const val MONTHS_AHEAD = 18L private const val MONTHS_AHEAD = 18L
@@ -85,16 +87,21 @@ fun MonthView(
LaunchedEffect(scrollToTodaySignal) { LaunchedEffect(scrollToTodaySignal) {
if (scrollToTodaySignal > 0) listState.animateScrollToItem((todayIndex - 1).coerceAtLeast(0)) 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) { LaunchedEffect(listState, weekCount) {
snapshotFlow { listState.firstVisibleItemIndex }.collect { idx -> snapshotFlow { listState.firstVisibleItemIndex }
val weekStart = firstVisible.plusWeeks(idx.toLong()) .map { firstVisible.plusWeeks(it.toLong()).plusDays(3).withDayOfMonth(1) }
val month = weekStart.plusDays(3) // mid-week → representative month .distinctUntilChanged()
onVisibleMonthChange(month) .collect { month ->
vm.ensureMonthLoaded(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()) { Column(Modifier.fillMaxSize()) {
// Fixed weekday header // Fixed weekday header
Row(Modifier.fillMaxWidth().padding(vertical = 2.dp)) { Row(Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
@@ -116,8 +123,7 @@ fun MonthView(
WeekRow( WeekRow(
weekStart = weekStart, weekStart = weekStart,
today = today, today = today,
events = state.events, eventsByDay = eventsByDay,
vm = vm,
lang = lang, lang = lang,
onDayClick = onDayClick, onDayClick = onDayClick,
onDayLongPress = onDayLongPress, onDayLongPress = onDayLongPress,
@@ -133,8 +139,7 @@ fun MonthView(
private fun WeekRow( private fun WeekRow(
weekStart: LocalDate, weekStart: LocalDate,
today: LocalDate, today: LocalDate,
events: List<CalEvent>, eventsByDay: Map<LocalDate, List<CalEvent>>,
vm: CalendarViewModel,
lang: String, lang: String,
onDayClick: (LocalDate) -> Unit, onDayClick: (LocalDate) -> Unit,
onDayLongPress: (LocalDate) -> Unit, onDayLongPress: (LocalDate) -> Unit,
@@ -150,7 +155,7 @@ private fun WeekRow(
DayCell( DayCell(
day = day, day = day,
isToday = day == today, isToday = day == today,
events = vm.eventsOn(day, events), events = eventsByDay[day] ?: emptyList(),
lang = lang, lang = lang,
onClick = { onDayClick(day) }, onClick = { onDayClick(day) },
onLongClick = { onDayLongPress(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 val offset = if (mondayFirst) dow - 1 else dow % 7
return date.minusDays(offset.toLong()) 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.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
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons 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.Delete
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.LocationOn 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.ui.tr
import com.scarriffle.calendarr.util.colorFromHex import com.scarriffle.calendarr.util.colorFromHex
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable @Composable
fun EventDetailSheet( fun EventDetailSheet(
event: CalEvent, event: CalEvent,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onEdit: () -> Unit, onEdit: () -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
onCopy: () -> Unit,
) { ) {
val lang = LocalLang.current val lang = LocalLang.current
var confirmDelete by remember { mutableStateOf(false) } var confirmDelete by remember { mutableStateOf(false) }
@@ -68,7 +73,7 @@ fun EventDetailSheet(
Spacer(Modifier.size(16.dp)) Spacer(Modifier.size(16.dp))
DetailRow(Icons.Filled.Schedule, eventDateRange(event, lang)) DetailRow(Icons.Filled.Schedule, eventDateRange(event, lang))
if (event.calendarName.isNotBlank()) { if (event.calendarName.isNotBlank()) {
DetailRow(Icons.Filled.Notes, event.calendarName) DetailRow(Icons.Filled.CalendarMonth, event.calendarName)
} }
if (event.location.isNotBlank()) { if (event.location.isNotBlank()) {
DetailRow(Icons.Filled.LocationOn, event.location) DetailRow(Icons.Filled.LocationOn, event.location)
@@ -77,7 +82,7 @@ fun EventDetailSheet(
DetailRow(Icons.Filled.Notes, event.notes) DetailRow(Icons.Filled.Notes, event.notes)
} }
Spacer(Modifier.size(20.dp)) Spacer(Modifier.size(20.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { FlowRow(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
if (canEdit) { if (canEdit) {
OutlinedButton(onClick = onEdit) { OutlinedButton(onClick = onEdit) {
Icon(Icons.Filled.Edit, contentDescription = null) Icon(Icons.Filled.Edit, contentDescription = null)
@@ -85,6 +90,11 @@ fun EventDetailSheet(
Text(tr("event.edit_title")) 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) { if (canDelete) {
OutlinedButton(onClick = { confirmDelete = true }) { OutlinedButton(onClick = { confirmDelete = true }) {
Icon(Icons.Filled.Delete, contentDescription = null, tint = MaterialTheme.colorScheme.error) Icon(Icons.Filled.Delete, contentDescription = null, tint = MaterialTheme.colorScheme.error)

View File

@@ -75,16 +75,19 @@ fun EventEditorSheet(
val context = LocalContext.current val context = LocalContext.current
val lang = LocalLang.current val lang = LocalLang.current
val existing = request.existing 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) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var title by remember { mutableStateOf(existing?.title ?: "") } var title by remember { mutableStateOf(template?.title ?: "") }
var allDay by remember { mutableStateOf(existing?.isAllDay ?: false) } var allDay by remember { mutableStateOf(template?.isAllDay ?: false) }
var location by remember { mutableStateOf(existing?.location ?: "") } var location by remember { mutableStateOf(template?.location ?: "") }
var description by remember { mutableStateOf(existing?.notes ?: "") } var description by remember { mutableStateOf(template?.notes ?: "") }
var color by remember { mutableStateOf(existing?.color) } var color by remember { mutableStateOf(template?.color) }
val initialStart = existing?.startDate val initialStart = template?.startDate
val initialEnd = existing?.endDate val initialEnd = template?.endDate
var startDate by remember { var startDate by remember {
mutableStateOf(initialStart?.let { LocalDate.ofInstant(it, zone) } ?: request.date) mutableStateOf(initialStart?.let { LocalDate.ofInstant(it, zone) } ?: request.date)
} }
@@ -95,7 +98,7 @@ fun EventEditorSheet(
mutableStateOf( mutableStateOf(
initialEnd?.let { initialEnd?.let {
val d = LocalDate.ofInstant(it, zone) 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 } ?: request.date
) )
} }
@@ -103,7 +106,7 @@ fun EventEditorSheet(
mutableStateOf(initialEnd?.let { LocalTime.ofInstant(it, zone).withSecond(0).withNano(0) } ?: LocalTime.of(10, 0)) 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() val id = calendarKey(ev.source, ev.calendarId).substringAfter(":").toIntOrNull()
writableCalendars.firstOrNull { it.source == ev.source && it.numericId == id } writableCalendars.firstOrNull { it.source == ev.source && it.numericId == id }
} }
@@ -139,7 +142,11 @@ fun EventEditorSheet(
.padding(bottom = 32.dp), .padding(bottom = 32.dp),
) { ) {
Text( 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, style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(vertical = 8.dp), 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.Arrangement
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.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize 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.shape.CircleShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Divider import androidx.compose.material3.Divider
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -25,7 +28,6 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -38,7 +40,6 @@ import androidx.compose.runtime.setValue
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.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel 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.LocalAppSettings
import com.scarriffle.calendarr.ui.tr import com.scarriffle.calendarr.ui.tr
import com.scarriffle.calendarr.util.colorFromHex import com.scarriffle.calendarr.util.colorFromHex
import com.scarriffle.calendarr.util.contrastingTextColor
private val PALETTE = listOf( private val PALETTE = listOf(
"#4285f4", "#ea4335", "#34a853", "#fbbc05", "#46bdc6", "#9c27b0", "#ff7043", "#7090c0", "#4285f4", "#ea4335", "#34a853", "#fbbc05", "#46bdc6", "#9c27b0", "#ff7043", "#7090c0",
@@ -120,19 +122,37 @@ fun SettingsScreen(
Divider(Modifier.padding(vertical = 16.dp)) Divider(Modifier.padding(vertical = 16.dp))
Section(tr("settings.hourheight")) Section(tr("settings.hourheight"))
Text("${settings.hourHeight} dp", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) ChipRow(
Slider( options = listOf(
value = settings.hourHeight.toFloat(), "28" to tr("settings.hourheight.compact"),
onValueChange = { settings = settings.copy(hourHeight = it.toInt()) }, "44" to tr("settings.hourheight.normal"),
onValueChangeFinished = { update(settings) }, "60" to tr("settings.hourheight.comfort"),
valueRange = 28f..100f, "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")) 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")) 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)) Divider(Modifier.padding(vertical = 16.dp))
Section(tr("settings.cache.title")) Section(tr("settings.cache.title"))
@@ -162,29 +182,35 @@ private fun ChipRow(options: List<Pair<String, String>>, selected: String, onSel
} }
} }
@OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
private fun ColorChooser(label: String, current: String, onPick: (String) -> Unit) { private fun ColorChooser(label: String, current: String, onPick: (String) -> Unit) {
Column(Modifier.padding(vertical = 6.dp)) { // Show the user's current colour even if it isn't one of the presets.
Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) val swatches = remember(current) {
Row(Modifier.padding(top = 4.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) { (if (PALETTE.any { it.equals(current, ignoreCase = true) }) PALETTE else listOf(current) + PALETTE)
PALETTE.forEach { hex -> }
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) val selected = hex.equals(current, ignoreCase = true)
Box( Box(
Modifier.size(28.dp).clip(CircleShape).background(colorFromHex(hex)) Modifier.size(34.dp).clip(CircleShape).background(colorFromHex(hex))
.then(if (selected) Modifier.border(2.dp, Color.White, CircleShape) else Modifier) .then(if (selected) Modifier.border(3.dp, MaterialTheme.colorScheme.onBackground, CircleShape) else Modifier)
.clickable { onPick(hex) }, .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,
)
}

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The launcher icon inset so it sits comfortably inside the round splash mask. -->
<inset xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@mipmap/ic_launcher"
android:inset="18%" />

View File

@@ -0,0 +1,10 @@
<resources>
<style name="Theme.Calendarr" parent="Theme.Material3.DayNight.NoActionBar">
<item name="android:windowBackground">@color/splash_bg</item>
<item name="android:statusBarColor">@color/splash_bg</item>
<item name="android:navigationBarColor">@color/splash_bg</item>
<!-- Android 12+ system splash screen -->
<item name="android:windowSplashScreenBackground">@color/splash_bg</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/splash_icon</item>
</style>
</resources>

View File

@@ -1,5 +1,10 @@
<resources> <resources>
<color name="splash_bg">#0B1220</color>
<style name="Theme.Calendarr" parent="Theme.Material3.DayNight.NoActionBar"> <style name="Theme.Calendarr" parent="Theme.Material3.DayNight.NoActionBar">
<item name="android:windowBackground">?android:colorBackground</item> <!-- Dark window background avoids a white flash before Compose draws. -->
<item name="android:windowBackground">@color/splash_bg</item>
<item name="android:statusBarColor">@color/splash_bg</item>
<item name="android:navigationBarColor">@color/splash_bg</item>
</style> </style>
</resources> </resources>