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]
|
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",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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…",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
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>
|
<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>
|
||||||
|
|||||||
Reference in New Issue
Block a user