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:
@@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
5
app/src/main/res/drawable/splash_icon.xml
Normal file
5
app/src/main/res/drawable/splash_icon.xml
Normal 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%" />
|
||||
10
app/src/main/res/values-v31/themes.xml
Normal file
10
app/src/main/res/values-v31/themes.xml
Normal 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>
|
||||
@@ -1,5 +1,10 @@
|
||||
<resources>
|
||||
<color name="splash_bg">#0B1220</color>
|
||||
|
||||
<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>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user