From b8eb6597ecc0bb8932edf31983114920b380f0a0 Mon Sep 17 00:00:00 2001 From: Guido Schmit Date: Sun, 31 May 2026 14:19:56 +0200 Subject: [PATCH] feat: HA/Google edit, full-page event detail, KW+zigzag divider, HSV picker, splash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Home Assistant & Google events now editable/deletable (server PUT/DELETE endpoints wired; saveEvent had no HA/Google update branch -> created dupes) - Event detail is now a full screen (Scaffold) instead of a bottom sheet: long notes scroll, action buttons no longer hidden behind the system nav bar; distinct calendar vs notes icons - Month view: calendar-week number on Mondays, month-boundary divider with zigzag step (month_divider_color), month label in month_label_color, text/line contrast applied - Compact top bar with smaller title; prev/next chevrons present in month view and scroll one month at a time - Real HSV colour picker (SV field + hue slider + hex) for primary, accent, today, month-divider and month-label colours - Black status/navigation bars; portrait-locked; crisp custom splash with app name, high-res icon and © Scarriffle Co-Authored-By: Claude Opus 4.8 --- app/src/main/AndroidManifest.xml | 1 + .../calendarr/data/CalendarRepository.kt | 53 +++-- .../calendarr/data/remote/CalendarrApi.kt | 22 +- .../scarriffle/calendarr/ui/CalendarrRoot.kt | 14 ++ .../java/com/scarriffle/calendarr/ui/L10n.kt | 2 + .../scarriffle/calendarr/ui/SplashScreen.kt | 45 ++++ .../ui/calendar/CalendarFormatting.kt | 16 ++ .../calendarr/ui/calendar/CalendarScreen.kt | 155 ++++++++---- .../ui/calendar/CalendarViewModel.kt | 3 + .../calendarr/ui/calendar/MonthView.kt | 223 ++++++++++++------ .../ui/components/ColorPickerDialog.kt | 152 ++++++++++++ .../calendarr/ui/event/EventDetailScreen.kt | 158 +++++++++++++ .../calendarr/ui/event/EventDetailSheet.kt | 133 ----------- .../calendarr/ui/settings/SettingsScreen.kt | 56 ++--- .../res/drawable-nodpi/ic_splash_logo.png | Bin 0 -> 22168 bytes app/src/main/res/drawable/splash_icon.xml | 4 +- app/src/main/res/values-v31/themes.xml | 7 +- app/src/main/res/values/themes.xml | 9 +- 18 files changed, 738 insertions(+), 315 deletions(-) create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/SplashScreen.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/components/ColorPickerDialog.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/event/EventDetailScreen.kt delete mode 100644 app/src/main/java/com/scarriffle/calendarr/ui/event/EventDetailSheet.kt create mode 100644 app/src/main/res/drawable-nodpi/ic_splash_logo.png diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6494c5c..b7303f7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -14,6 +14,7 @@ diff --git a/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt b/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt index 6aadd8b..3cb56e6 100644 --- a/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt +++ b/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt @@ -305,31 +305,56 @@ class CalendarRepository @Inject constructor( calendarDbId: Int, title: String, start: Instant, end: Instant, isAllDay: Boolean, location: String, description: String, ) = guarded { - api.createGoogleEvent( - jsonBody( - "calendar_db_id" to calendarDbId, "title" to title, - "start" to Dates.format(start, isAllDay), "end" to Dates.format(end, isAllDay), - "allDay" to isAllDay, "location" to location, "description" to description, - ) - ).ensureSuccess() + api.createGoogleEvent(simpleEventBody("calendar_db_id", calendarDbId, title, start, end, isAllDay, location, description)) + .ensureSuccess() } + suspend fun updateGoogleEvent( + gcalDbId: Int, eventId: String, title: String, start: Instant, end: Instant, + isAllDay: Boolean, location: String, description: String, + ) = guarded { + api.updateGoogleEvent(gcalDbId, eventId, simpleEventBody(null, null, title, start, end, isAllDay, location, description)) + .ensureSuccess() + } + + suspend fun deleteGoogleEvent(gcalDbId: Int, eventId: String) = + guarded { api.deleteGoogleEvent(gcalDbId, eventId).ensureSuccess() } + suspend fun createHAEvent( calendarId: Int, title: String, start: Instant, end: Instant, isAllDay: Boolean, location: String, description: String, ) = guarded { - api.createHAEvent( - jsonBody( - "calendar_id" to calendarId, "title" to title, - "start" to Dates.format(start, isAllDay), "end" to Dates.format(end, isAllDay), - "allDay" to isAllDay, "location" to location, "description" to description, - ) - ).ensureSuccess() + api.createHAEvent(simpleEventBody("calendar_id", calendarId, title, start, end, isAllDay, location, description)) + .ensureSuccess() + } + + suspend fun updateHAEvent( + calendarId: Int, uid: String, title: String, start: Instant, end: Instant, + isAllDay: Boolean, location: String, description: String, + ) = guarded { + api.updateHAEvent(calendarId, uid, simpleEventBody(null, null, title, start, end, isAllDay, location, description)) + .ensureSuccess() } suspend fun deleteHAEvent(calendarId: Int, uid: String) = guarded { api.deleteHAEvent(calendarId, uid).ensureSuccess() } + /** Body for Google/HA events (no per-event colour). */ + private fun simpleEventBody( + calKey: String?, calId: Int?, title: String, start: Instant, end: Instant, + isAllDay: Boolean, location: String, description: String, + ) = jsonBody( + buildMap { + if (calKey != null && calId != null) put(calKey, calId) + put("title", title) + put("start", Dates.format(start, isAllDay)) + put("end", Dates.format(end, isAllDay)) + put("allDay", isAllDay) + put("location", location) + put("description", description) + } + ) + private fun eventBody( calendarId: Int?, title: String, start: Instant, end: Instant, isAllDay: Boolean, location: String, description: String, color: String?, diff --git a/app/src/main/java/com/scarriffle/calendarr/data/remote/CalendarrApi.kt b/app/src/main/java/com/scarriffle/calendarr/data/remote/CalendarrApi.kt index 65cb42a..d831a0d 100644 --- a/app/src/main/java/com/scarriffle/calendarr/data/remote/CalendarrApi.kt +++ b/app/src/main/java/com/scarriffle/calendarr/data/remote/CalendarrApi.kt @@ -170,10 +170,30 @@ interface CalendarrApi { @POST("api/google/events") suspend fun createGoogleEvent(@Body body: RequestBody): Response + @PUT("api/google/events/{gcalDbId}/{eventId}") + suspend fun updateGoogleEvent( + @Path("gcalDbId") gcalDbId: Int, + @Path("eventId") eventId: String, + @Body body: RequestBody, + ): Response + + @HTTP(method = "DELETE", path = "api/google/events/{gcalDbId}/{eventId}", hasBody = false) + suspend fun deleteGoogleEvent( + @Path("gcalDbId") gcalDbId: Int, + @Path("eventId") eventId: String, + ): Response + @POST("api/homeassistant/events") suspend fun createHAEvent(@Body body: RequestBody): Response - @DELETE("api/homeassistant/events/{calendarId}/{uid}") + @PUT("api/homeassistant/events/{calendarId}/{uid}") + suspend fun updateHAEvent( + @Path("calendarId") calendarId: Int, + @Path("uid") uid: String, + @Body body: RequestBody, + ): Response + + @HTTP(method = "DELETE", path = "api/homeassistant/events/{calendarId}/{uid}", hasBody = false) suspend fun deleteHAEvent( @Path("calendarId") calendarId: Int, @Path("uid") uid: String, diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/CalendarrRoot.kt b/app/src/main/java/com/scarriffle/calendarr/ui/CalendarrRoot.kt index b61e566..c8e63fc 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/CalendarrRoot.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/CalendarrRoot.kt @@ -5,8 +5,12 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.hilt.navigation.compose.hiltViewModel import com.scarriffle.calendarr.ui.auth.LoginScreen @@ -19,12 +23,22 @@ fun CalendarrRoot(vm: MainViewModel = hiltViewModel()) { val route by vm.route.collectAsState() val settings by vm.settings.collectAsState() + var showSplash by remember { mutableStateOf(true) } + LaunchedEffect(Unit) { + kotlinx.coroutines.delay(1200) + showSplash = false + } + CalendarrTheme(settings) { CompositionLocalProvider( LocalLang provides L10n.resolved(settings.language), LocalAppSettings provides settings, ) { Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + if (showSplash) { + SplashScreen() + return@Surface + } when (route) { AppRoute.SETUP -> ServerSetupScreen(onConfigured = vm::onServerConfigured) AppRoute.LOGIN -> LoginScreen( diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt b/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt index 7ff2d54..30de0b8 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt @@ -95,6 +95,7 @@ object L10n { "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.detail_title" to "Termin", "event.source" to "Quelle", "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…", @@ -195,6 +196,7 @@ object L10n { "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.detail_title" to "Event", "event.source" to "Source", "event.save" to "Save", "event.add" to "Add", "event.delete_confirm" to "Delete this event?", "accounts.title" to "Accounts", "accounts.loading" to "Loading accounts…", diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/SplashScreen.kt b/app/src/main/java/com/scarriffle/calendarr/ui/SplashScreen.kt new file mode 100644 index 0000000..9bf141a --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/SplashScreen.kt @@ -0,0 +1,45 @@ +package com.scarriffle.calendarr.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.scarriffle.calendarr.R + +/** Custom startup screen: app name on top, crisp icon centered, copyright at the bottom. */ +@Composable +fun SplashScreen() { + Box(Modifier.fillMaxSize().background(Color.Black)) { + Text( + "Calendarr", + modifier = Modifier.align(Alignment.TopCenter).padding(top = 96.dp), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.SemiBold, + color = Color.White, + ) + Image( + painter = painterResource(R.drawable.ic_splash_logo), + contentDescription = null, + modifier = Modifier.align(Alignment.Center).size(148.dp).clip(RoundedCornerShape(32.dp)), + ) + Text( + "© Scarriffle", + modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 40.dp), + style = MaterialTheme.typography.bodySmall, + color = Color.White.copy(alpha = 0.6f), + ) + } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFormatting.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFormatting.kt index 01ee814..71c016c 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFormatting.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFormatting.kt @@ -49,6 +49,22 @@ fun weekdayLabels(mondayFirst: Boolean, lang: String): List { fun timeLabel(instant: Instant, lang: String): String = DateTimeFormatter.ofPattern("HH:mm", L10n.locale(lang)).withZone(zone).format(instant) +/** Opacity for secondary text (week number, overflow count) per text_contrast 1..4. */ +fun secondaryTextOpacity(contrast: Int): Float = when (contrast.coerceIn(1, 4)) { + 1 -> 0.40f + 2 -> 0.55f + 3 -> 0.72f + else -> 0.92f +} + +/** Opacity for regular grid lines per line_contrast 1..4. */ +fun gridLineOpacity(contrast: Int): Float = when (contrast.coerceIn(1, 4)) { + 1 -> 0.15f + 2 -> 0.30f + 3 -> 0.50f + else -> 0.80f +} + fun localTime(instant: Instant): LocalTime = LocalTime.ofInstant(instant, zone) fun localDate(instant: Instant): LocalDate = LocalDate.ofInstant(instant, zone) diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt index df89cab..4dfe8f3 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt @@ -2,9 +2,13 @@ package com.scarriffle.calendarr.ui.calendar import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ChevronLeft @@ -22,9 +26,9 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -35,15 +39,17 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import com.scarriffle.calendarr.domain.model.AppSettings import com.scarriffle.calendarr.domain.model.CalEvent import com.scarriffle.calendarr.domain.model.CalViewType import com.scarriffle.calendarr.ui.LocalLang import com.scarriffle.calendarr.ui.accounts.AccountsScreen -import com.scarriffle.calendarr.ui.event.EventDetailSheet +import com.scarriffle.calendarr.ui.event.EventDetailScreen import com.scarriffle.calendarr.ui.event.EventEditorSheet import com.scarriffle.calendarr.ui.menu.MenuSheet import com.scarriffle.calendarr.ui.profile.ProfileScreen @@ -77,57 +83,36 @@ fun CalendarScreen( // Continuous month scrolling val monthListState = rememberLazyListState() var todaySignal by remember { mutableIntStateOf(0) } + var monthJumpSignal by remember { mutableIntStateOf(0) } + var monthJumpTarget by remember { mutableStateOf(null) } var visibleMonth by remember { mutableStateOf(state.currentDate) } val isMonth = state.viewType == CalViewType.MONTH val barTitle = if (isMonth) titleForView(CalViewType.MONTH, visibleMonth, lang) else titleForView(state.viewType, state.currentDate, lang) + fun goPrev() { + if (isMonth) { monthJumpTarget = visibleMonth.minusMonths(1); monthJumpSignal++ } else vm.navigatePrev() + } + fun goNext() { + if (isMonth) { monthJumpTarget = visibleMonth.plusMonths(1); monthJumpSignal++ } else vm.navigateNext() + } + fun goToday() { + if (isMonth) todaySignal++ else vm.moveToToday() + } + Scaffold( topBar = { - TopAppBar( - title = { - Text(barTitle, maxLines = 1, overflow = TextOverflow.Ellipsis) - }, - navigationIcon = { - IconButton(onClick = { showMenu = true }) { - Icon(Icons.Filled.Menu, contentDescription = tr("nav.menu")) - } - }, - actions = { - if (!isMonth) { - IconButton(onClick = vm::navigatePrev) { - Icon(Icons.Filled.ChevronLeft, contentDescription = null) - } - } - IconButton(onClick = { if (isMonth) todaySignal++ else vm.moveToToday() }) { - Icon(Icons.Filled.Today, contentDescription = tr("nav.today")) - } - if (!isMonth) { - IconButton(onClick = vm::navigateNext) { - Icon(Icons.Filled.ChevronRight, contentDescription = null) - } - } - IconButton(onClick = { showFilter = true }) { - Icon(Icons.Filled.FilterList, contentDescription = tr("filter.button")) - } - Box { - IconButton(onClick = { viewMenuOpen = true }) { - Icon(state.viewType.icon, contentDescription = tr("view.change")) - } - DropdownMenu(expanded = viewMenuOpen, onDismissRequest = { viewMenuOpen = false }) { - CalViewType.entries.forEach { type -> - DropdownMenuItem( - text = { Text(tr("view.${type.key}")) }, - leadingIcon = { Icon(type.icon, contentDescription = null) }, - onClick = { - viewMenuOpen = false - vm.setViewType(type) - }, - ) - } - } - } - }, + CompactTopBar( + title = barTitle, + viewType = state.viewType, + viewMenuOpen = viewMenuOpen, + onMenu = { showMenu = true }, + onPrev = { goPrev() }, + onToday = { goToday() }, + onNext = { goNext() }, + onFilter = { showFilter = true }, + onViewMenuToggle = { viewMenuOpen = it }, + onSelectView = { vm.setViewType(it) }, ) }, floatingActionButton = { @@ -153,6 +138,8 @@ fun CalendarScreen( vm = vm, monthListState = monthListState, scrollToTodaySignal = todaySignal, + monthJumpSignal = monthJumpSignal, + monthJumpTarget = monthJumpTarget, onVisibleMonthChange = { visibleMonth = it }, onEventClick = { detailEvent = it }, onDayClick = { date -> vm.goToDate(date, CalViewType.DAY) }, @@ -186,21 +173,21 @@ fun CalendarScreen( } detailEvent?.let { ev -> - EventDetailSheet( + EventDetailScreen( event = ev, - onDismiss = { detailEvent = null }, + onClose = { detailEvent = null }, onEdit = { detailEvent = null editor = EditorRequest(ev, localDate(ev.startDate)) }, - onDelete = { - vm.deleteEvent(ev) {} - detailEvent = null - }, onCopy = { detailEvent = null editor = EditorRequest(existing = null, date = localDate(ev.startDate), prefill = ev) }, + onDelete = { + vm.deleteEvent(ev) {} + detailEvent = null + }, ) } @@ -238,6 +225,8 @@ private fun CalendarBody( vm: CalendarViewModel, monthListState: androidx.compose.foundation.lazy.LazyListState, scrollToTodaySignal: Int, + monthJumpSignal: Int, + monthJumpTarget: LocalDate?, onVisibleMonthChange: (LocalDate) -> Unit, onEventClick: (CalEvent) -> Unit, onDayClick: (LocalDate) -> Unit, @@ -249,6 +238,8 @@ private fun CalendarBody( vm = vm, listState = monthListState, scrollToTodaySignal = scrollToTodaySignal, + monthJumpSignal = monthJumpSignal, + monthJumpTarget = monthJumpTarget, onVisibleMonthChange = onVisibleMonthChange, onDayClick = onDayClick, onDayLongPress = onDayLongPress, @@ -284,6 +275,66 @@ private fun loadingPlaceholder() { } } +@Composable +private fun CompactTopBar( + title: String, + viewType: CalViewType, + viewMenuOpen: Boolean, + onMenu: () -> Unit, + onPrev: () -> Unit, + onToday: () -> Unit, + onNext: () -> Unit, + onFilter: () -> Unit, + onViewMenuToggle: (Boolean) -> Unit, + onSelectView: (CalViewType) -> Unit, +) { + Surface(color = MaterialTheme.colorScheme.background) { + Row( + Modifier.fillMaxWidth().height(50.dp).padding(horizontal = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CompactIcon(Icons.Filled.ChevronLeft, onPrev) + TextButton(onClick = onToday, contentPadding = PaddingValues(horizontal = 6.dp)) { + Text(tr("nav.today"), fontSize = 13.sp) + } + CompactIcon(Icons.Filled.ChevronRight, onNext) + Text( + title, + modifier = Modifier.weight(1f).padding(horizontal = 4.dp), + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleMedium, + ) + CompactIcon(Icons.Filled.FilterList, onFilter, tr("filter.button")) + Box { + CompactIcon(viewType.icon, { onViewMenuToggle(true) }, tr("view.change")) + DropdownMenu(expanded = viewMenuOpen, onDismissRequest = { onViewMenuToggle(false) }) { + CalViewType.entries.forEach { type -> + DropdownMenuItem( + text = { Text(tr("view.${type.key}")) }, + leadingIcon = { Icon(type.icon, contentDescription = null) }, + onClick = { onViewMenuToggle(false); onSelectView(type) }, + ) + } + } + } + CompactIcon(Icons.Filled.Menu, onMenu, tr("nav.menu")) + } + } +} + +@Composable +private fun CompactIcon( + icon: androidx.compose.ui.graphics.vector.ImageVector, + onClick: () -> Unit, + contentDescription: String? = null, +) { + IconButton(onClick = onClick, modifier = Modifier.size(40.dp)) { + Icon(icon, contentDescription = contentDescription, modifier = Modifier.size(22.dp)) + } +} + /** Distinct calendars currently present in the cache, for the filter sheet. */ private fun allKnownCalendars(vm: CalendarViewModel): List { val st = vm.state.value diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt index 0853c09..d48bb08 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt @@ -295,6 +295,8 @@ class CalendarViewModel @Inject constructor( when (existing.source) { "local" -> repository.updateLocalEvent(existing.id, title, start, end, isAllDay, location, description, color) "caldav" -> repository.updateCalDAVEvent(existing.id, existing.url, calendar.numericId, title, start, end, isAllDay, location, description, color) + "homeassistant" -> repository.updateHAEvent(calendar.numericId, existing.id, title, start, end, isAllDay, location, description) + "google" -> repository.updateGoogleEvent(calendar.numericId, existing.id, title, start, end, isAllDay, location, description) else -> createForSource(calendar, title, start, end, isAllDay, location, description, color) } } else { @@ -325,6 +327,7 @@ class CalendarViewModel @Inject constructor( "local" -> repository.deleteLocalEvent(event.id) "caldav" -> repository.deleteCalDAVEvent(event.id, event.url, calendarNumericId(event)) "homeassistant" -> repository.deleteHAEvent(calendarNumericId(event) ?: 0, event.id) + "google" -> repository.deleteGoogleEvent(calendarNumericId(event) ?: 0, event.id) else -> Unit } } diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt index 9c7db02..3210a80 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/MonthView.kt @@ -5,18 +5,19 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Divider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -27,6 +28,9 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -35,37 +39,47 @@ import androidx.compose.ui.unit.sp import com.scarriffle.calendarr.domain.model.CalEvent import com.scarriffle.calendarr.ui.LocalAppSettings import com.scarriffle.calendarr.ui.LocalLang +import com.scarriffle.calendarr.ui.tr import com.scarriffle.calendarr.util.colorFromHex import com.scarriffle.calendarr.util.contrastingTextColor +import java.time.DayOfWeek import java.time.LocalDate import java.time.format.TextStyle import java.time.temporal.ChronoUnit +import java.time.temporal.IsoFields import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map private const val MONTHS_BACK = 18L private const val MONTHS_AHEAD = 18L +private val ROW_HEIGHT = 82.dp -/** - * Continuous, vertically scrolling month calendar (matches the iOS app). - * There are no prev/next buttons — the user scrolls through weeks and the - * top-bar title follows the currently visible month. - */ +private enum class DividerEdge { NONE, TOP, BOTTOM } + +/** Continuous, vertically scrolling month calendar (matches the iOS app). */ @Composable fun MonthView( state: CalendarUiState, vm: CalendarViewModel, listState: LazyListState, scrollToTodaySignal: Int, + monthJumpSignal: Int, + monthJumpTarget: LocalDate?, onVisibleMonthChange: (LocalDate) -> Unit, onDayClick: (LocalDate) -> Unit, onDayLongPress: (LocalDate) -> Unit, onEventClick: (CalEvent) -> Unit, ) { val lang = LocalLang.current + val settings = LocalAppSettings.current val mondayFirst = state.weekStartsOnMonday val today = LocalDate.now() + val dividerColor = colorFromHex(settings.monthDividerColor, Color(0xFF7090C0)) + val labelColor = colorFromHex(settings.monthLabelColor, MaterialTheme.colorScheme.onSurfaceVariant) + val gridColor = MaterialTheme.colorScheme.outline.copy(alpha = gridLineOpacity(settings.lineContrast)) + val secondaryText = MaterialTheme.colorScheme.onBackground.copy(alpha = secondaryTextOpacity(settings.textContrast)) + val firstVisible = remember(mondayFirst) { startOfWeek(today.withDayOfMonth(1).minusMonths(MONTHS_BACK), mondayFirst) } @@ -75,19 +89,20 @@ fun MonthView( val weekCount = remember(firstVisible, end) { (ChronoUnit.WEEKS.between(firstVisible, end).toInt() + 1).coerceAtLeast(1) } - val todayIndex = remember(firstVisible) { - ChronoUnit.WEEKS.between(firstVisible, startOfWeek(today, mondayFirst)).toInt() - } + fun weekIndexOf(date: LocalDate): Int = + ChronoUnit.WEEKS.between(firstVisible, startOfWeek(date, mondayFirst)).toInt().coerceIn(0, weekCount - 1) - // Initial scroll to today's week. - LaunchedEffect(Unit) { - listState.scrollToItem((todayIndex - 1).coerceAtLeast(0)) - } - // "Today" button. + val todayIndex = remember(firstVisible) { weekIndexOf(today) } + + LaunchedEffect(Unit) { listState.scrollToItem((todayIndex - 1).coerceAtLeast(0)) } LaunchedEffect(scrollToTodaySignal) { if (scrollToTodaySignal > 0) listState.animateScrollToItem((todayIndex - 1).coerceAtLeast(0)) } - // Track visible month + trigger on-demand loads (only when the month changes). + LaunchedEffect(monthJumpSignal) { + if (monthJumpSignal > 0 && monthJumpTarget != null) { + listState.animateScrollToItem(weekIndexOf(monthJumpTarget.withDayOfMonth(1))) + } + } LaunchedEffect(listState, weekCount) { snapshotFlow { listState.firstVisibleItemIndex } .map { firstVisible.plusWeeks(it.toLong()).plusDays(3).withDayOfMonth(1) } @@ -98,13 +113,10 @@ fun MonthView( } } - // 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)) { + Row(Modifier.fillMaxWidth().padding(vertical = 3.dp)) { weekdayLabels(mondayFirst, lang).forEach { label -> Text( label, @@ -115,16 +127,19 @@ fun MonthView( ) } } - Divider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f)) LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) { items(weekCount) { index -> - val weekStart = firstVisible.plusWeeks(index.toLong()) WeekRow( - weekStart = weekStart, + weekStart = firstVisible.plusWeeks(index.toLong()), today = today, eventsByDay = eventsByDay, lang = lang, + dividerColor = dividerColor, + gridColor = gridColor, + labelColor = labelColor, + secondaryText = secondaryText, + todayColor = colorFromHex(settings.todayColor), onDayClick = onDayClick, onDayLongPress = onDayLongPress, onEventClick = onEventClick, @@ -134,37 +149,67 @@ fun MonthView( } } -@OptIn(ExperimentalFoundationApi::class) @Composable private fun WeekRow( weekStart: LocalDate, today: LocalDate, eventsByDay: Map>, lang: String, + dividerColor: Color, + gridColor: Color, + labelColor: Color, + secondaryText: Color, + todayColor: Color, onDayClick: (LocalDate) -> Unit, onDayLongPress: (LocalDate) -> Unit, onEventClick: (CalEvent) -> Unit, ) { val days = (0 until 7).map { weekStart.plusDays(it.toLong()) } - Row( - Modifier - .fillMaxWidth() - .height(78.dp), - ) { - days.forEach { day -> - DayCell( - day = day, - isToday = day == today, - events = eventsByDay[day] ?: emptyList(), - lang = lang, - onClick = { onDayClick(day) }, - onLongClick = { onDayLongPress(day) }, - onEventClick = onEventClick, - modifier = Modifier.weight(1f).fillMaxSize(), + val boundaryCol = (1 until 7).firstOrNull { days[it].dayOfMonth == 1 } + val rowStartsNewMonth = days[0].dayOfMonth == 1 + val cwLabel = tr("cal.cw") + + BoxWithConstraints(Modifier.fillMaxWidth().height(ROW_HEIGHT)) { + val cellW = maxWidth / 7 + Row(Modifier.fillMaxSize()) { + days.forEachIndexed { idx, day -> + val edge = when { + boundaryCol != null -> if (idx < boundaryCol) DividerEdge.BOTTOM else DividerEdge.TOP + rowStartsNewMonth -> DividerEdge.TOP + else -> DividerEdge.NONE + } + DayCell( + day = day, + isToday = day == today, + isMonday = day.dayOfWeek == DayOfWeek.MONDAY, + weekNumber = day.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR), + cwLabel = cwLabel, + events = eventsByDay[day] ?: emptyList(), + lang = lang, + edge = edge, + dividerColor = dividerColor, + gridColor = gridColor, + labelColor = labelColor, + secondaryText = secondaryText, + todayColor = todayColor, + onClick = { onDayClick(day) }, + onLongClick = { onDayLongPress(day) }, + onEventClick = onEventClick, + modifier = Modifier.weight(1f).fillMaxHeight(), + ) + } + } + // Vertical connector at the month boundary column (the "step"). + if (boundaryCol != null) { + Box( + Modifier + .offset(x = cellW * boundaryCol) + .width(1.5.dp) + .fillMaxHeight() + .background(dividerColor), ) } } - Divider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f)) } @OptIn(ExperimentalFoundationApi::class) @@ -172,54 +217,85 @@ private fun WeekRow( private fun DayCell( day: LocalDate, isToday: Boolean, + isMonday: Boolean, + weekNumber: Int, + cwLabel: String, events: List, lang: String, + edge: DividerEdge, + dividerColor: Color, + gridColor: Color, + labelColor: Color, + secondaryText: Color, + todayColor: Color, onClick: () -> Unit, onLongClick: () -> Unit, onEventClick: (CalEvent) -> Unit, modifier: Modifier = Modifier, ) { - val settings = LocalAppSettings.current - val todayColor = colorFromHex(settings.todayColor) - val monthLabelColor = colorFromHex(settings.monthLabelColor, MaterialTheme.colorScheme.onSurfaceVariant) val isFirst = day.dayOfMonth == 1 + val onBg = MaterialTheme.colorScheme.onBackground - Column( + Box( modifier = modifier - .combinedClickable(onClick = onClick, onLongClick = onLongClick) - .padding(horizontal = 1.dp, vertical = 2.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - if (isFirst) { - Text( - day.month.getDisplayName(TextStyle.SHORT, com.scarriffle.calendarr.ui.L10n.locale(lang)), - fontSize = 8.sp, - color = monthLabelColor, - modifier = Modifier.padding(end = 2.dp), - ) + .drawBehind { + val gridW = 0.5.dp.toPx() + val divW = 1.5.dp.toPx() + // top border (thick divider on a month boundary, otherwise thin grid) + if (edge == DividerEdge.TOP) { + drawLine(dividerColor, Offset(0f, 0f), Offset(size.width, 0f), divW) + } else { + drawLine(gridColor, Offset(0f, 0f), Offset(size.width, 0f), gridW) + } + // bottom border only when the month ends inside this row + if (edge == DividerEdge.BOTTOM) { + drawLine(dividerColor, Offset(0f, size.height), Offset(size.width, size.height), divW) + } + // right grid line + drawLine(gridColor, Offset(size.width, 0f), Offset(size.width, size.height), gridW) } - Box(contentAlignment = Alignment.Center) { - if (isToday) { - Box( - Modifier - .height(18.dp) - .width(18.dp) - .clip(RoundedCornerShape(9.dp)) - .background(todayColor), + .combinedClickable(onClick = onClick, onLongClick = onLongClick) + .padding(horizontal = 1.dp), + ) { + Column( + Modifier.fillMaxSize().padding(top = 3.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (isFirst) { + Text( + day.month.getDisplayName(TextStyle.SHORT, com.scarriffle.calendarr.ui.L10n.locale(lang)).uppercase(), + fontSize = 8.sp, + fontWeight = FontWeight.SemiBold, + color = labelColor, + modifier = Modifier.padding(end = 2.dp), ) } - Text( - "${day.dayOfMonth}", - fontSize = 11.sp, - fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal, - color = if (isToday) todayColor.contrastingTextColor() else MaterialTheme.colorScheme.onBackground, - ) + Box(contentAlignment = Alignment.Center) { + if (isToday) { + Box(Modifier.height(18.dp).width(18.dp).clip(RoundedCornerShape(9.dp)).background(todayColor)) + } + Text( + "${day.dayOfMonth}", + fontSize = 11.sp, + fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal, + color = if (isToday) todayColor.contrastingTextColor() else onBg, + ) + } + } + events.take(3).forEach { ev -> EventChip(ev) { onEventClick(ev) } } + if (events.size > 3) { + Text("+${events.size - 3}", fontSize = 8.sp, color = secondaryText) } } - events.take(3).forEach { ev -> EventChip(ev) { onEventClick(ev) } } - if (events.size > 3) { - Text("+${events.size - 3}", fontSize = 8.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + // Calendar week number, bottom-right of the Monday cell. + if (isMonday) { + Text( + "$cwLabel $weekNumber", + fontSize = 8.sp, + color = secondaryText, + modifier = Modifier.align(Alignment.BottomEnd).padding(end = 3.dp, bottom = 2.dp), + ) } } } @@ -257,7 +333,6 @@ private fun buildEventsByDay(events: List): Map>() 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 diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/components/ColorPickerDialog.kt b/app/src/main/java/com/scarriffle/calendarr/ui/components/ColorPickerDialog.kt new file mode 100644 index 0000000..4132d12 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/components/ColorPickerDialog.kt @@ -0,0 +1,152 @@ +package com.scarriffle.calendarr.ui.components + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.dp +import com.scarriffle.calendarr.util.colorFromHex +import com.scarriffle.calendarr.util.toHex + +/** + * Full HSV colour picker: saturation/value field + hue slider + hex input. + * Returns a "#RRGGBB" string via [onConfirm]. + */ +@Composable +fun ColorPickerDialog( + initial: String, + title: String, + onDismiss: () -> Unit, + onConfirm: (String) -> Unit, +) { + val initHsv = remember(initial) { + FloatArray(3).also { android.graphics.Color.colorToHSV(colorFromHex(initial).toArgb(), it) } + } + var hue by remember { mutableFloatStateOf(initHsv[0]) } + var sat by remember { mutableFloatStateOf(initHsv[1]) } + var value by remember { mutableFloatStateOf(initHsv[2]) } + var hexText by remember { mutableStateOf(colorFromHex(initial).toHex()) } + + val current = Color.hsv(hue, sat, value) + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + Column(Modifier.fillMaxWidth()) { + // Saturation / Value field + Box( + Modifier + .fillMaxWidth() + .height(170.dp) + .clip(RoundedCornerShape(10.dp)) + .background(Brush.horizontalGradient(listOf(Color.White, Color.hsv(hue, 1f, 1f)))) + .background(Brush.verticalGradient(listOf(Color.Transparent, Color.Black))) + .pointerInput(Unit) { + detectTapGestures { o -> + sat = (o.x / size.width).coerceIn(0f, 1f) + value = (1f - o.y / size.height).coerceIn(0f, 1f) + hexText = Color.hsv(hue, sat, value).toHex() + } + } + .pointerInput(Unit) { + detectDragGestures { change, _ -> + change.consume() + sat = (change.position.x / size.width).coerceIn(0f, 1f) + value = (1f - change.position.y / size.height).coerceIn(0f, 1f) + hexText = Color.hsv(hue, sat, value).toHex() + } + }, + ) { + Canvas(Modifier.matchParentSize()) { + val cx = sat * size.width + val cy = (1f - value) * size.height + drawCircle(Color.White, radius = 9f, center = Offset(cx, cy), style = Stroke(width = 3f)) + drawCircle(Color.Black, radius = 12f, center = Offset(cx, cy), style = Stroke(width = 1f)) + } + } + Spacer(Modifier.height(14.dp)) + + // Hue slider + Box( + Modifier + .fillMaxWidth() + .height(26.dp) + .clip(RoundedCornerShape(13.dp)) + .background(Brush.horizontalGradient((0..6).map { Color.hsv(it * 60f, 1f, 1f) })) + .pointerInput(Unit) { + detectTapGestures { o -> + hue = (o.x / size.width * 360f).coerceIn(0f, 360f) + hexText = Color.hsv(hue, sat, value).toHex() + } + } + .pointerInput(Unit) { + detectDragGestures { change, _ -> + change.consume() + hue = (change.position.x / size.width * 360f).coerceIn(0f, 360f) + hexText = Color.hsv(hue, sat, value).toHex() + } + }, + ) { + Canvas(Modifier.matchParentSize()) { + val cx = (hue / 360f) * size.width + drawCircle(Color.White, radius = 10f, center = Offset(cx, size.height / 2f), style = Stroke(width = 3f)) + } + } + Spacer(Modifier.height(14.dp)) + + Row(verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.size(40.dp).clip(CircleShape).background(current).border(1.dp, MaterialTheme.colorScheme.outline, CircleShape)) + Spacer(Modifier.size(12.dp)) + OutlinedTextField( + value = hexText, + onValueChange = { raw -> + hexText = raw + val parsed = colorFromHex(raw, Color.Unspecified) + if (parsed != Color.Unspecified) { + val hsv = FloatArray(3) + android.graphics.Color.colorToHSV(parsed.toArgb(), hsv) + hue = hsv[0]; sat = hsv[1]; value = hsv[2] + } + }, + label = { Text("Hex") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + } + }, + confirmButton = { TextButton(onClick = { onConfirm(current.toHex()) }) { Text("OK") } }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Abbrechen") } }, + ) +} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/event/EventDetailScreen.kt b/app/src/main/java/com/scarriffle/calendarr/ui/event/EventDetailScreen.kt new file mode 100644 index 0000000..584c6e8 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/ui/event/EventDetailScreen.kt @@ -0,0 +1,158 @@ +package com.scarriffle.calendarr.ui.event + +import androidx.compose.foundation.background +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.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CalendarMonth +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Dns +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material.icons.filled.Notes +import androidx.compose.material.icons.filled.Schedule +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.scarriffle.calendarr.domain.model.CalEvent +import com.scarriffle.calendarr.ui.LocalLang +import com.scarriffle.calendarr.ui.calendar.eventDateRange +import com.scarriffle.calendarr.ui.tr +import com.scarriffle.calendarr.util.colorFromHex + +/** Full-screen event detail (mirrors the iOS detail page). */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EventDetailScreen( + event: CalEvent, + onClose: () -> Unit, + onEdit: () -> Unit, + onCopy: () -> Unit, + onDelete: () -> Unit, +) { + val lang = LocalLang.current + var confirmDelete by remember { mutableStateOf(false) } + val canEdit = event.source in setOf("local", "caldav", "homeassistant", "google") + val canDelete = canEdit + + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(tr("event.detail_title")) }, + navigationIcon = { + IconButton(onClick = onClose) { Icon(Icons.Filled.Close, contentDescription = tr("common.close")) } + }, + actions = { + if (canEdit) { + TextButton(onClick = onEdit) { Text(tr("event.edit_title")) } + } + }, + ) + }, + ) { padding -> + Column( + Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(20.dp), + ) { + Row(verticalAlignment = Alignment.Top) { + Box( + Modifier.width(6.dp).height(46.dp).clip(RoundedCornerShape(3.dp)) + .background(colorFromHex(event.effectiveColor)), + ) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text(event.title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold) + if (event.calendarName.isNotBlank()) { + Text(event.calendarName, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + Spacer(Modifier.height(20.dp)) + + DetailRow(Icons.Filled.Schedule, eventDateRange(event, lang)) + if (event.location.isNotBlank()) DetailRow(Icons.Filled.LocationOn, event.location) + if (event.notes.isNotBlank()) DetailRow(Icons.Filled.Notes, event.notes) + if (event.calendarName.isNotBlank()) DetailRow(Icons.Filled.CalendarMonth, event.calendarName) + DetailRow(Icons.Filled.Dns, event.source.replaceFirstChar { it.uppercase() }) + + Spacer(Modifier.height(28.dp)) + OutlinedButton(onClick = onCopy, modifier = Modifier.fillMaxWidth()) { + Icon(Icons.Filled.ContentCopy, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text(tr("event.copy_title")) + } + if (canDelete) { + Spacer(Modifier.height(12.dp)) + Button( + onClick = { confirmDelete = true }, + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.errorContainer, contentColor = MaterialTheme.colorScheme.onErrorContainer), + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Filled.Delete, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text(tr("common.delete")) + } + } + Spacer(Modifier.height(24.dp)) + } + } + } + + if (confirmDelete) { + AlertDialog( + onDismissRequest = { confirmDelete = false }, + title = { Text(tr("common.delete")) }, + text = { Text(tr("event.delete_confirm")) }, + confirmButton = { + TextButton(onClick = { confirmDelete = false; onDelete() }) { + Text(tr("common.delete"), color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { TextButton(onClick = { confirmDelete = false }) { Text(tr("common.cancel")) } }, + ) + } +} + +@Composable +private fun DetailRow(icon: ImageVector, text: String) { + Row(Modifier.fillMaxWidth().padding(vertical = 8.dp), verticalAlignment = Alignment.Top) { + Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(22.dp)) + Spacer(Modifier.width(14.dp)) + Text(text, style = MaterialTheme.typography.bodyLarge) + } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/event/EventDetailSheet.kt b/app/src/main/java/com/scarriffle/calendarr/ui/event/EventDetailSheet.kt deleted file mode 100644 index a54512d..0000000 --- a/app/src/main/java/com/scarriffle/calendarr/ui/event/EventDetailSheet.kt +++ /dev/null @@ -1,133 +0,0 @@ -package com.scarriffle.calendarr.ui.event - -import androidx.compose.foundation.background -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 -import androidx.compose.material.icons.filled.Notes -import androidx.compose.material.icons.filled.Schedule -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -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.vector.ImageVector -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import com.scarriffle.calendarr.domain.model.CalEvent -import com.scarriffle.calendarr.ui.LocalLang -import com.scarriffle.calendarr.ui.calendar.eventDateRange -import com.scarriffle.calendarr.ui.tr -import com.scarriffle.calendarr.util.colorFromHex - -@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) } - val canEdit = event.source == "local" || event.source == "caldav" - val canDelete = event.source == "local" || event.source == "caldav" || event.source == "homeassistant" - - ModalBottomSheet(onDismissRequest = onDismiss) { - Column(Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 28.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Box(Modifier.size(16.dp).clip(CircleShape).background(colorFromHex(event.effectiveColor))) - Spacer(Modifier.size(10.dp)) - Text( - event.title, - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.SemiBold, - ) - } - Spacer(Modifier.size(16.dp)) - DetailRow(Icons.Filled.Schedule, eventDateRange(event, lang)) - if (event.calendarName.isNotBlank()) { - DetailRow(Icons.Filled.CalendarMonth, event.calendarName) - } - if (event.location.isNotBlank()) { - DetailRow(Icons.Filled.LocationOn, event.location) - } - if (event.notes.isNotBlank()) { - DetailRow(Icons.Filled.Notes, event.notes) - } - Spacer(Modifier.size(20.dp)) - FlowRow(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - if (canEdit) { - OutlinedButton(onClick = onEdit) { - Icon(Icons.Filled.Edit, contentDescription = null) - Spacer(Modifier.size(6.dp)) - 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) - Spacer(Modifier.size(6.dp)) - Text(tr("common.delete"), color = MaterialTheme.colorScheme.error) - } - } - } - } - } - - if (confirmDelete) { - AlertDialog( - onDismissRequest = { confirmDelete = false }, - title = { Text(tr("common.delete")) }, - text = { Text(tr("event.delete_confirm")) }, - confirmButton = { - TextButton(onClick = { confirmDelete = false; onDelete() }) { - Text(tr("common.delete"), color = MaterialTheme.colorScheme.error) - } - }, - dismissButton = { - TextButton(onClick = { confirmDelete = false }) { Text(tr("common.cancel")) } - }, - ) - } -} - -@Composable -private fun DetailRow(icon: ImageVector, text: String) { - Row(Modifier.fillMaxWidth().padding(vertical = 6.dp), verticalAlignment = Alignment.Top) { - Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(20.dp)) - Spacer(Modifier.size(12.dp)) - Text(text, style = MaterialTheme.typography.bodyLarge) - } -} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsScreen.kt b/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsScreen.kt index 46f4dc6..e03e8cb 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsScreen.kt @@ -46,9 +46,10 @@ import androidx.hilt.navigation.compose.hiltViewModel import com.scarriffle.calendarr.domain.model.AppSettings import com.scarriffle.calendarr.domain.model.CalViewType import com.scarriffle.calendarr.ui.LocalAppSettings +import com.scarriffle.calendarr.ui.components.ColorPickerDialog import com.scarriffle.calendarr.ui.tr import com.scarriffle.calendarr.util.colorFromHex -import com.scarriffle.calendarr.util.contrastingTextColor +import com.scarriffle.calendarr.util.toHex private val PALETTE = listOf( "#4285f4", "#ea4335", "#34a853", "#fbbc05", "#46bdc6", "#9c27b0", "#ff7043", "#7090c0", @@ -116,9 +117,11 @@ fun SettingsScreen( Divider(Modifier.padding(vertical = 16.dp)) Section(tr("settings.colors")) - ColorChooser(tr("settings.color.primary"), settings.primaryColor) { update(settings.copy(primaryColor = it)) } - ColorChooser(tr("settings.color.accent"), settings.accentColor) { update(settings.copy(accentColor = it)) } - ColorChooser(tr("settings.color.today"), settings.todayColor) { update(settings.copy(todayColor = it)) } + ColorRow(tr("settings.color.primary"), settings.primaryColor) { update(settings.copy(primaryColor = it)) } + ColorRow(tr("settings.color.accent"), settings.accentColor) { update(settings.copy(accentColor = it)) } + ColorRow(tr("settings.color.today"), settings.todayColor) { update(settings.copy(todayColor = it)) } + ColorRow(tr("settings.color.divider"), settings.monthDividerColor) { update(settings.copy(monthDividerColor = it)) } + ColorRow(tr("settings.color.label"), settings.monthLabelColor) { update(settings.copy(monthLabelColor = it)) } Divider(Modifier.padding(vertical = 16.dp)) Section(tr("settings.hourheight")) @@ -182,35 +185,24 @@ private fun ChipRow(options: List>, selected: String, onSel } } -@OptIn(ExperimentalLayoutApi::class) @Composable -private fun ColorChooser(label: String, current: String, onPick: (String) -> Unit) { - // 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) +private fun ColorRow(label: String, current: String, onPick: (String) -> Unit) { + var showPicker by remember { mutableStateOf(false) } + Row( + Modifier.fillMaxWidth().clickable { showPicker = true }.padding(vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.size(28.dp).clip(CircleShape).background(colorFromHex(current)).border(1.dp, MaterialTheme.colorScheme.outline, CircleShape)) + Spacer(Modifier.size(12.dp)) + Text(label, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) + Text(colorFromHex(current).toHex(), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } - 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(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)) - } - } - } + if (showPicker) { + ColorPickerDialog( + initial = current, + title = label, + onDismiss = { showPicker = false }, + onConfirm = { showPicker = false; onPick(it) }, + ) } } diff --git a/app/src/main/res/drawable-nodpi/ic_splash_logo.png b/app/src/main/res/drawable-nodpi/ic_splash_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..a88fa5b32c2e7115677de60876983f7c8fb4d3cc GIT binary patch literal 22168 zcmd743s{ZW`!M`;AcxcpLlU+bHDRQt(4@2-BO!yChOqfjF%F?2ba--X9HWq=b|f(l z88JzRCk&y|2}!aeI@j*7JMG=`toL5)*}H@M|L^yH-+O)UdtGyx_IlR3*L}Y4b+5z0 z1@mY1>ocSeMN$3iXU|wjQJV0lCe=$F{#Q#UzM!aIMfNkMF5dB`y3p%psoV0`b*0`- zhWQzrrrnCa`K{ry=_C5DjGVf5*^?}tF-d>ESa`(T<=UVhH2+-}_UeuPU-Pery_q_4 z%>MGMgWs*Vd}P?>+Jz?eb<;*HyYSWiZ`9uh{%crvwy*v3a)H6b&?+mYpmjxfsbXhR zH2u;jLDH}@T)K0zL@KSi5yl>vK66OoPphuK7qTV$d>b-)U;is3qx{;HjCC7ZG7|G{ ztsA!}@w5&d_j;!0kn`I_>Wv>11M(Ie;L62Zxnx-xz1=|fd3@+2`O*_Zsl}!;V>Fq3 z8y&=z)2|cqA8R&eE4h)AH!K@@T|KT~h_xLwkRuB2N4pU@mh+@L$O^ zwEAtv@NI^hhacDQ>-n|tS(cE^6|$jy*}N^59;9(;Y6>I$5d)U$OhK=HTgX~#KT-2j_yn8>9O$$o-1WaN$|W^sMTSMxh#HKT z61N$i7RG8?)KoHeuFxe%$hswlw)z21SwJ-Y&m8^@dY5r*XwRV-xau&5nvhnxdD~v^*PO< z)C;GC`O6AqJS7|T@xYjE<7drRKc=QbRUF`67QXzso$+FB7)<@bU$H*%YNR%1Q!Wz^ z{4(9Fy}AEpDr=04Cn?UQ48+6~X7Bj6h>G}BMmQ~VPs@VdNYk!(FtqOP+Io_2-B{SA z@8Vg-sW2mEfn;r&0^ z8BIUC2!EjL;QVZX?t;3WBQ(0eh)YlGvzdA_P1)J`00(?BvA}T0c7ZDauwUxv_rc$< zTv9@K;7MWG#-+D;Z?5aPa?WStoh!6&E_pUCMtvwXB#M_gV=^5pEw+%>RcS3VOBu?kZlPr<~SsR+8+T;9wE5(gpX za?2(95iPy4#>#UsVYy1~+F4k!il0FAeq3Q1FxLT4_W+Agp(05-0sx4bL1Q%S<`>vD za*A%Q7S7Zq@z@IA7xe z|M0vwm^Y1$o~)q>cy&fDXc4l?q^5%RSYZcpAm+kkUwlbFut?c8=`%{Eql4)82+(X# zt4?sYveCN+;@QUew-XY19@)705gUj+ujJ;@16;#7wLRFgz@NOBEQiQ5J$m-S(u6;G z8wRTrX6lc?(rhaEvN!03nsql+oucQhY*nD4BZ$aW&Qy>%1ChJ7{LH+X6G1 zv?}dJpEdQpb6w2S)CJ0wv~_YK0T`;{s*4>;TQNUD$rd14W$kj&`>jK;XbRf8cE7rO z{$boblz7lWHSFOe=8@=>X>>dw!(76vMY>7`#d$ zoLyz&4_n_%rffdcfj;4HN=@m<=2@Qob%nYLAskq+o6u)Hw_44^BHZYUCFZF`1Pq)^ z_>1c@FD5!9oA&?37i1gP3alxY4D;iz{|MfR7jB2kihE3G0(Ggi^nF7(KqA>-a2GOh z^ksQB>@2O%P7-y&?iJs;6j=V5+{}WY(YE>X)rQjI`8? zL^dGU{U{iBf<>v7#=cBeF`R^3U_8;mn)FARl&tW^fV?uv0q`Il=2E)W&bxPKFw&)f z%hB(_jM86pcIQIYU|!wV5eq*RclG5Ka&h{nBlZ19sJuV%u$~rj%d;RDvqf0k(Il36 zT1L{5qixj}|Guk@Xj3QkUX*lhHx>-G5h1(id+;?0`Y>a-#gsFn`Bt1|B!p%&*&pLi zUv32fbhRhrcnzB!(vm;00X_t4Q@J@vZp@mO9ssU{0D!K**B|U7K>U=-?x1?lty3sd zwFh%I@(!KEi)cFXsfWqWFT2t1=&Q9OqBI!X;6x$^j5*B0WG~)o63U9ZgSj#bMP)<8 zXlx;tsS-0-LOG*dKQ+Q^5f;9f!`UHmW9BvrX&rU5rQ6ie$h^o=!$0JW(l)`)z!2?% z-sjUIEx#V!2otiIHv)pGRhM{7`CJGvU9+P}lBTxuL@LS8RHh6H<%s&eYIOi70vP!) zkc>dKg}v?BvtriMt7k89+uy4?MJ3=-NSiTzPe`Tvbd#@)Svo9PvsWeuTSn>=%oPGyDBlpReVO-33F*mNS$P0LR|0AU7oa=>D6CPBf}`+TO~bd)RtX+z=w z(lD5Jzzwm(DI6BMbWfA%P|m&_#2(CPK8_MW1Z?Lt(iLlx6pD)w(!O?hP8TzKq_zerHj5Mw6sF>S51~Dty|A%K=?e zYFt0o)WhU(qGqojz^*&#oMhODhq368F7=rWX3;{A-rf4Hw8&U?;fA0)mY$nUJZS9^ z*UT`XN?&dsNy{@Wf^R+`C_~63%K=zG*mxWqV~bW}pV%2e&+)a06L;nKR%v2qMarEr?*jlpKFw`W!XP%Kl*iW2@(qk$dGe( z!cH;-cL z?H*4sYZCiV&{kE4#ENk~>a60OBT9XjQIpJ-6eFpzzy`Y(5U^rxb(Q7NF=1_*H+eEG z3MJ|Ge%$i31#(FaTOFne9;l4jAAe1{q*C5b$&1u%ROr65bUQj6oGc$Wi{7$471k&w zd&|&o^31}~vwXKiK||d7GUk5#A9X!>LeXlOaJof7>!s5cu!=aX*2p}Vf2Bry3fkU} zwUou9)$gdP7HTEYF_b4`zF9kbe`p<~9MYu+a?t$kb7s2nFt5b_;i=STI!X(@OzIOs z=j9;j_GW+Ug3-kSjWWrN^XOH{twuFJ;+E+Ntn{n##SHFe{wJ4EIC^zBWV6s|V(C_y z_E*0G_k^s|1+?&4f~5X=57hHp(6!rg;jKl3+6?xU30xudonwiYm*folp3djbJX zYMLv5OE*~W=cbh&@HK`5G50!7fbth zWG{>xalP2mcyWH0w&7YTvStg*C3m#1qQ8eD&!&W2X8Cl~yX1@GejeT!p!AcXid}8Y z2k~yevq&yzu0!*YHlC8+6F;;yntH%n5iWEzR}uf)kZ#1YN92<&HA}u?=iqQ>)mD9)lG_IGGFs z#rTj9r6Uz1d%6N6yJRra(ax2FwmdisxOGlb`mt_)`5Krjx-Sgk; z`u_9gs+D)48#vY<;zJ94*o>FYKb{vFV1jgA7yU%4Dl|%~0y}rRqj$SSd=!#PX~L3f zUfH!`M?)P0T(Gc?S^gnNc;k^V9k}T?Pgl8RaLpzcB6hrf;5K7b!z(I4w9x2d$Z(+voT<;=(Zl54Ngac16NPlBP5CV2!Q6+@9VW$!5 zpci+`3`cOu>SgzUS;V6?q>RFzv zl|X<`z?O2*fD1H~uQD}a|JT4>y4@5&rL)nLcffh1R^!@;MpD19izpzXWMSrY6(S+s z<~-32+cPF!NSHH|txJ4pwdE6pEE+fk7%>MfjbQQx#LW+`&BNnhMi%>~FQ_&!OsLMS4f70pHC2m*HA#O3?y}b`GIDY(&ANtDU zo|H+F<|a4iwllU&@?9iUomwH7s2CPOA3g%5qB+*$jh8=Uf`Hwl0pC!E*tlC6Q*|*X zWKyEZDxsxamYY5trc9(EzbMSWo|yK~cSh6sX6BR2ubFNJc3co%f{n(^%t_|7tW+uh z;;L~C$C~OsZd)0H_`KEeKOrw`*LtK2HGqIHSjV`JTa-;_YLTK{GqKFV^jJCpYMfEu zv&}mxTs=VROk%ePHXZ|Kq?*dygl*nVKGK;u|0&F{QGj7emR01^qg|>~bQwR+#9S`U z-t86^TwBl<{cd~pX@sP{Y1R%CbSH@ANfOO9w-!~MG)efh*AnA~wqMW161!_^KcMoh zL#BwGJq5{x>)7C9LiHI)Cb`7v+EgY+qD;?>h54nSm8}UcjjW>@N8Zmyvmjqtg2Kqc zBQ!@SvwW|8=|tFnqP2Go2K*f(P))JEeX z=uCyf`T&E;FHYit^7%#arDDa~1~7f&7&H#nx{l~&rC8xSUMu&>{J$4Hw(5P`R~w*9 zSqktJ#c|op&pXYIR()gr=tn>9{x!z*r4Ba?_=*lSX0lOnOi`k&tvSqe?GV(<4|=(r z+0>eglUTG5vTs@g1G&mgZZtI!7RqKU+I}c(VPd}s+N`qG;m&+CD1VJClP!Mc>aY9L zZae(3^~Z?Ciir@q85s*@OThEx9fvdcQ%)8jP|g77q9j-w@}g;(yIjpU3*(&#;oZ!j z#oM;JHrS*#=;`NyHZ-hhV0xEJ}jFBT6V|!yn{^&8YL_Ln|?AbwX z2$wAiJlR}41-e-OKs4Sy@NDbTl|b)1UZKTzCkdxit9We6~+4t{%5hemy2;qt~GQXK+9ze?Qx1F=KP1@ z--@@)oJRqJc?~?d4%uK3MHmDcB9qn$^NDsvecaKz_D=$SFb_W1aNR!gdp7wkZu|L4 z{gz06zIAb1@>T(WpX~;Kq>3vf#q_OJLoj3QTl=p}Ak367BADH)Ddx?``{OZ^fsK># z)D&qCxJ(BiBYUG}2fx*jW;&vWZN9h^ozKjfr#nK4>`5shOTj{JM~`K_+8ig&9SKqx zy+VDin&!$^5Xwha!q{;3>VU)E6&v%^kFJSc+bE6o9Ja!Gqkv2%d9#~l)dAP{jnX<~ z=)7d&To3Qy;dje6O4_9Vv(eT$^R)?2?O{}dyy79?=54647G z7nzwP%@lka`z(`M`6`)nC>5w(pyf_=-)dbdzfd~-?i0QHU;%TUHDfY-kT1sQVI{Cc zu$4L+KUum(Px0MrV9nFIASz&2Z>$&1(zyAASh-h9e3biI*G+CUR@EP3!#`B6jBe>a zP)M}pA?f9a>N8KidL@~Ra_-zJf$vWsTP$Y+B&Jy8ub50U+e6XRcU<$#8EDsL+AL=?Xb zC)UBgk9zgNIJsP8`~HQ8U27rz@vIn~SnumAGksv?mDzHNt{o69wcVB=stpgl!9EJ$ z9IwvgCXE0gO(m{HPe_e8RU>AR>{AS#-6}cWdibUn`>$nEnZIipDvqNojtX{!zP#`u z6^F7kKX?2;qf^HOZ81I#1H|Z_!wT-MkDC!%*w*3_<16QkH#O5qYY+FI0 zr+k_{mM)2vMyG}etLP6`0=!B>9Bo@(_hstNxrK*vCs6hZ;9iVX%P?<1cDj9B|URd45 zTh+6WC90m_o?MyyFnPg3Q#5MunWz`&9FC*7%SJ-Q)llE}Bc=8G19$E0O}QRyWUBdYyV$-beCxAx(_1W= z8lR%8sDyR*U0FO;^utrg-vu8?yS=`E)_w0TWlDwK zMc0AEW?@gtRVF&7@FW_v?WKXR_Lb$0i?4&Wht)<)*rwjJOLBw0sqK!Y{eXg_s5vd} zolmrp;m#&U6eYF9b`$u*GeG#MDsOqcqg&e^Ve1ERQl%-EqBjQ&f{)^+_EAU|CDuv3 zCE<-h|0sGi+iVu=qS@FGNg{8w&TmE2?KIQLW|c9P@f8o5?P>1|yw6`8M{85*WpqB> z`wV99r1Bq)tGx=1V$Rg{-IQ2dB5M68ZJB1j(hkiVMvXO>2VdN{tI)_)Sbfn4^Glu^ z=e}VR-=TV_vctWyBgRQpd-O6|dPw8Tk#9?yC9l(}9=ED9Iqpfrih=9byA&`j=pEWs zXzhL85L9=&4h3ngLrznYY>(aw?~+A8FKYT*k_eW?_pnp-2;24HU0!C}VZ`icY4Y^` z-~-IIq`&^^Og0X>khL1EbCsZT!C}dY7obiVg4UxkwmTTplOOYiY}{t*=94l}pzHRU zwYQVdDTPy z*dw{7H6URJfPZqG-clHB`@XLcET8gxcmxMkK66-MtI$hqTrF+-1*=@8E%nVka<`$ zcLzdKe>P9~(MVCMha&TtA}9B@B*pMV2~o=!De>g?4%@jaS~`onYVcpiRa{EXIBJ^#Q5?z)-~*U{_`!>=h4RDZ5thZ?pKK#o;z~KOiw~eEz0N3 z{MQPBuzIG~E61}SzU>9kpt7#~@Jsyg>rF@3n5?Li>1A>gnC->VR^nVI@XmGJ%W*CH$grn(?WVBO{_R*+z zw7)CbqwD>?nBJD@#GA)o?bDa-`wa&+GeurojT`qbFO`0-t3Yl0ja;`s9Nj(aXjS*o zyrDrzx5Voz2xsox9}89u&k&{%&O6c{_ALRec7Hf~ zj02|-DY+VuT)TxA=lnDKd8S9*D@!-7miSbFI;4v3-sb@iPyxQ86Q($q&#YYSQ9ND5 zUF5MUy@Sss0MYRpDaEBjDcdcnZd?=R#dB;@j1gE>xhUsD*!G;!5T!nvmx`#`77;e{ z=B1?OXJqouBeV71Z_c6YdRv*17KIh~-FlKE2jAWiY}520o+*(#qX#@bO{!sAsGk(& zz(u#@`bP;h3Im>+H>!~<74vOc_vB_`uvO8C_Xv_Juh zv;$30`~k6vI`we~#L)%nM3@U5a7^v7M}?9S{A*;7xj+0&51fhE0~t^*!rspiJM7ND zGl@F!T|7)+MWKYU%mI5|ej*~nKtqhn|HtlwLONzr{_P0+#%bWfB;N%Aa=I2KmCV>J z;HeMK#T2KHzk&9Hw|75bA9&Hwzweol+nk;?1x2Orfm(s<_vcX$ND%R7L%Dy*l`i&TBAbt6UGsB^BW1d92QaT;>8gO>^lg zngwMI6^=o#(2o#e;+_}+n;)jXXhe59Abr0ZR#{NcBBY+Nzkqh4{$?=EjhTUllH+PC zr^!u2Gcd3_vk7|#a$1!@K)=l<0@jUrTQk!}L3;?ldnV@n1^U85M8nq_oQZDK(4?cDg44JVT|?yZ_{CJ zO+D)P4Ac)2DitO=NDJe={)FLE%QYDDzkpq;fu-#OKN^^)Ly(OE4tSanwP0W&qMWh5 zpS~-gbFTv(NhKNqoU2qU9XbeOWOj818py{ij1i(hz3`T|q5Wnx3wxR95g!c=?`y+y z12hsU^`M6sofCZ6H|*_NMi+s-#;t;+$M8PH_5gleVrXwR9Yuob;tlaY=oeTVLu*Qfp`YmOA1wBDd}0LtQ`p;7)C{OjEecF8g&qMKKirGz9f$zbN#Yh zHCHyX9xxoaNJYX-Ujd5$%o=Kz)G5%y7dA07lNxdUu= z<8zrnO$(XiUociUePjxJOE_&PmlpA9N#M!@oWVWtim;1o1RTx-<+q+hQYQkk*ifz-+_; zrQZb{#U35p8W5^N;b?u_7?{dw+Zr{wUmcA1MU1@X@qnq^f0I?vU#EnX?F$~Z8kl8%>fA5CEtNe zgu3PrA*=B?fpthL1ScTz*p4&zrsG6t8Md<0sjfKukHfanFv0?I5KBF5nLCf`|4+eZ zF2hL^4<2@;+QGxFRy%mu{%r@JZUo@@FaSHi0G{}J*xg+M6>%{)A;FD%eDxoNu&{QR zdvoKut5@H7HEu`IKZxq*hy;-*%Y+Y?y?V}_pAI9NS^tE_c~Z~PbAgI5%}e`cdXJy; z@)h@k&T}*~x~hkTT-3VsFYu@6<1tQ$TC|M`N;!G+&j7p8piYkU^S_7beClT{FK2?# zo-yC9Hby=4qrB_=xgX9sxIo2$z7Q%g@Y9x=uIMrM$>B#0t0NcVgPz8HabRk$68YHt z(>!LS`I@oe0)wbSc$Iu5ejl(M1e(DHPq2}uWRIE&ts$z`YOZ;LVU@D=sH*ij^`zG^ zyV2QrADSQic?*tkLY~iZ)8Rb_`mqUw6rNI+h;$6S>hgg7ktUKO)Fdc!6opB%Vh;4wBt z1KiHcbxQttiqp1Rck%-4W8j4ij#TQD-2MGh9@Nj@;}Zq|>T1h33+h_MA0kIS^0zy( z5atBB_?gWi6;RIn-**RQ;F9m=8FrE1xa5FPaP|rm%~ft-Io0)#*wH4ku^JbyZx98P zdyp-$Pf*Y+`tyk0=w}y|8)8-(S;C*bi~RWzOii}KM9(Ri?y)CpPlCabojC`e*Y=ZN9wm-PN7hqmJHs)Fj%nnKOM87YrK( zvU*TP6Z3&F>SfGDAKxP|cQ!JNaZy0g!u`ZRcj{1_+FMcmwk1 zbz`*}aeu+8>`|Y9q4qaiu7ay{UZX<=8p7fW&4@9AZ9YflXUmTkt-m~_8?Wc`(eXfhEM)<=l&zrUpOh+J`k0m1uF zBOK-HG<*~5>DhVs3N#Go?RS*JmuAs=orl}Wm|}uE=y-3}We(PI2`+TBV>6m1`OA&{ zU>J}^Dpj&p7#J;!uCIJ~Z$IeBY7&O+C3cS>rm zsOB882TiuUG#bJubN#ThwW6Otq-^C1&xC5XchX~)Mk^@)b$t4*+?=AP0^S#{QY4&Y0B=;=ZB+ww8s-ZVRim?4*rEQHeuWsG(MxTEC`?o6m9 zrN8fIn=Rw#=|>#`UnF|76Sf3zJc^~CT;`b3x#QWnU z=lMO|E*ANOT>5!P6eNn)Y%necC$#oEXBzmO#)P?x>_$UelizT$fB;@DFkkR+{Jh*z zMrKfFyVb&9Bf$x?%jF|#-RNmCfGUG)xW%oZ*!cE8b~5c zLK+SG&m;-tvy??S=o-io*7te>i&3^4Rw~ELK?NiqC&3KKWQ_7a6b_t@3z0L(3(#X) z&P=UarI9;+9x$IiE2m{ZB9KFR{e}Wa1)EQS)>WeS{6shoixW8kNs0N7+H%t9|Wp-6Q3r@@t-^Z*vx)5)BH>y%# zDA|h5`YJPqY}nh*`va^-?i9iu_7zx|ffzvwv;4aNJ%$m*VG+vteQ@~)b7$fxTURA_ zqO;Iz5O6ddb-w4CGoT}&P^VJHDpNmTOi&q}NMOvl;C3|J{4C$4QQ75%pEl+g&Tn9? zgjd&cr|*BCasZUHhlNbU*TLko`aLn52l@n)LmatD=5x8wKHs(n8>+7fY}TB}rX__< zv3YlMCuaX5O*(5fH+THeg~k7%Lwg$-FKJ<4*dX`|Z(GAIixdg+6qU=96bR*B$B zPA+3fxyW^g)jb&Ra&pYPM70PclS>g8r%Xzjt=n9*ADd1N7Aw;;4UmPZq5><^_Hqs1od=K4TPn*n zu#V%)K7cq61<)s{fvU~}iw0#42QN32#h?Fw8tMN;lBB}bqNM+yaM=tq7f?|DQE6g8 z{|J0lfg~+vegB1XY-BO5<+}Tf(nJ9Fuiz*@cwJaGo$2u(IF*j=54bo5-z*^Opp}FE zPXLE3$&OetW-mS*q{IbMBrIx3!$3r^YEq#N37m=nI44t43W*qO>hL6y14Y>P81K$6 zIYTT416?`s8ke@V)6cuyEY*5kt`+;EO!yeH;!9pNeWF6ki`e8hHxr6fqN$but3VSwnmZgC15{&eto*TNd*(}CQ^!i`SL zi%(m34OG^xPrb?o>A!LTjij97NzG2_TZA3fS%_3ZHh2Y%Pq>IipU8enPsQQSk@H&; z0|zAE;m>;RlzE@X9Us<0WIHv6>`Mo)Aph%Ye#rP3?(c?H{(6N=fTT*u_Qiq51r{M@ zSASEU8R~%}h&lM5GcT{B8WL6ZXurS+0C|7qkr)?EPwn|T_JF_~lQQOTyV0AihoT38 ze09gO-)z?7cnQOnW~1kyK{}g*bVw8S0{5>?U}N~rAHwi{6?7DVc_={t8^J`SLp*cm zGWygZq-#r9Q*tJY7m`UO9rA@S6RwleB_BENU-My2RaL5=DA%AYqz(!aR|uli@ylN< z6XK=VKM7t?1x)kzelXHepygcGY?8#>6b2}>p+$6 z3#<+)8!pI&H86h4JQP&(d8i7&2NV}zol=jruS916cZ~^HrOiU`aSTQ>;u8Asu;-&x zZQ|S2W1m(sC;O{lk3GnhvJNTmGpudZFdgh=p{8dSszKuSFI(x?DKHSH6CM1aojrj^C58CfAQT^A*y#e)EH9< z&lhNzD^r+0%Idz4AEj!V6ys8TEpHfvRjD(ILvJT+3YhoDORt#kNj!XUx0?A-^i~x6 zx0@*xBA+onpqFp}N9Q1ggVy3YIHFsbe0m&z#=@PNljDDE2iDM28{DasYx7u7q6At?>ng$Ms<|VAB(tM5^j!sjf(wDxX%RjpDWwnKM@wD6>R}^`VdIqil&u>?(a^^Zu zC+OA6(=*UjRWT0i_b{o#6XAjYoQOqYF9h4nO1c9LcQ5JBn&KRtcn?Q(U4#p6yr2!` zBALorh`H!3xYjRBxpS`d&Gb#tfrc3y(LgQ0Nx@a`g#)Rv;2d!+PFUJ8#`r)wK*lZ{ z>?dw5cKPal9 zK!@O{3-k`+)F`t^d=!rN*_oh9&m^`Gw@p{5&kkJty}U(`UIuxF9|;ZkI7Q50W#}K1 ztN6hW=YK-HMJ@8~Gdu9&!bhcadlT3hE^j%L{7{srdED^I+)s7FjxBrpg;$ETsP_JR zm&J8mrz{1OCXl4$kppEPI43Ot-B(QtGt3z#q~OQ}oQzAsR2czJq7J1ZIKz;{Z@a_q zW1W8Ubp5rXO;mh{!F=ohO(^Ndu@Q(3-mT9CQt5_d@NF#wP@rOW8lcv^f>@>dX}|o+EP0vmAm+fZ$<= zSaL1{o0bGX3!^PGc?6QI?Av_1t<3Cx}D zo-1^#8WVL5vIEJ7wa>jX&L6ebmDC-pEL>+~KOh3l7>;0Loj@}fHt$?==5#%X*0A0& z^|FGbl1_-+qMQ@m3Xa_bExD0ayL#4c8DYQBL)-%A!33YSLdL~3X8&8RXwyE8(Ghcc ziRZTcdlB_M^8I{C+?LW+G3ti)*V}#(LfSQQ#q&x3$wA}oJU(h~mJP2SJi`93CihlI z&IHX{hfkvZmA`qc5eg~SynvE2{gfZ41TL-?)xwfZ`0CTp)>#x;QQF0}@t+EqYA8vH z9)7wu@_W{C!}x$n8b9@%9XO`d%?HrX|9-g~ z9-lCtbQW^U%viT$Lp6vy%CsRJ7khm_zrblu{@u#WCk(GR%cc3w6D@t3VXD+2s{q9+EQfc+F!>j!auNcTdc$V9&DuIJmjdazI zD`QX2OQq~{e#g&7xbt*dRH8DprTVtdBVh0CvH0Z*1CSU#$uE3!Vzpn8pW(V>1+<1H zotq_~4k)a!fSTrNf1GU z{ox2$3e){Td+@zNYb))a`p?dh#bb$HPs{&6Wfcdgf#Z*hK|KOAOU!GV)8;w59>Nc| z3`jaR5AMmtxg5B6u9&7GPU6BNzJJJHH^dSPx1K_-Il!rZ6Y}N-OtG_uT7{>A)9n9{ z=P7_ArSTdYe5gMgCy=xEaBKE|we`x6F{y6!Zz8ICuK-m-%ff1djc`3qm zRoM2KhM?vR|6)Fjb9=6~^1P#3b~miHEqh*-iQod6ALkPa)R6aN$|Z$IxQ!v(#~G6a z2V9GQs{HyR7=ug?E3%S+v*!hz>b@Vq=hRkK3N#22T~Hm9a1M@7+^hhq$<7XcpAcU$ zcfOR7wxo=z{WBO>sEOC|I7_fTOy-wW--0& z@G=L)oG=JTf&ZK@y->7@$8q19$>ZF>Quz7dQ=H42_;#jmgx`&6h9;MjlFM$G>$ zvK!NXPdN7oPJCP;Hr83A63&e!YbIcmGkM(w7_fYPvzFf^VGlSh(M3z&q%Emv5H8w* z>j9lVFlps=)Pk4z=qP?Vq#cfh==rSxR6vf?ckQqLfm-meTmK-@^F9H{+2?ckbbVTW zdPANcf7zP9L=Sf7h+K*^Qhvia)oOPD5s0y z2LITWFMxVz%L%GU$*K$UgmaRHQmza>;@#P0!5QJf0cch?jTEv0zrp(iCL(-*8e2Pg zj)vEn#8k958BPa%2544Hsz%D+ruZ=bXByK~jNVD8p?EXwGguSK=syD}u>P)7G`>)% zt;{>Z^nZc3mCw@!V&&5!fbM2MSK*%m*)XPqEyve@)9||tbL;y}$*(uUNinuh;vC4$ zH^u0@ zgsPgfC8OJ@)Esn~?t8p52JlkD5l8N1xv%Esxh0?~oe7odWMzw8ic(Q=qS{&oY#w&; zD;ndk(gL-56R1n!tHjDn?puAoW+5{&9xSvQ~j!4juwYlZXEU2hwn3M+9!%X1c zYN*L&bY@7xA&n{9z_vcaKvNzg8#!eG7m_8Cs5+EnGXGAlw3m*At&r5)p=4>AKb;NMYuCAE1$|Nb!iZ`0Ok~sSc zn&{i>ZgF>5zp|DManv>}-_M3zLu=@MqdpVW@pAXJ4GQXJZg;l8cH^m3bTmorHx~46 zB3#W=892U4YT~Q8_Efj&DK<`FL8akq(!egr2|&j{$Z)OGo%Q$Ao2)_j(w=tIP%`0B zrpHC*5a8Q6h+uIHY zP)4mk;j1ax*vzo>vtQJ?<$Ls$Lq&F%??9X&sXP_@A*GJv!xdfHMBbXCo~G{Z0{PM& zVP6d4z>fd75L~Nx2t9Xw4ndTtHoJZdj@=#J*p7vBK=2mdT_-j0Hg>9&2mJBKz|ZhA zR=6fALHm3ObmN$!ck{Z4PQN8{4+n6vkEtc(btV%oS2H^_j#tCF`4^lewe!$=iX%Np z=1EtrAf<{17s2Co$*Ec7NiFxZ zi&;XzYYn>Zmpoq`_^z~KM>K(5a#x}@#V7eIQuE_YKps;9e1wN5TL*&%sU9eTLXNlx zJB)gP@P*e7ny~^|Q)`b4bUT9(+$nr3r6S(1=tXpuXsi$r9!lLBssW-=E+WNnvsH&$ zVyg#{jZ@*8ZKrmHJvyA1C#G8w&xsOTXC`PbRO=!0&6oqh91)L+4Ac(x%c{ z)Gt2TqfC+qL|`ro@ROSxL!OP(k^=!1t0Hy>QP^GboLrRgX)}o8oMy-37MG1`JgMWl zy~sj~ppX8QQdv@JgufbDkn*N!Y&)bZSi{e5l6Dq;FBHF9T3|kXxsEJNZA#x^J$KpY zc53%op;HI#6T-CcOiH9~nEJPTq9VS1u5oo}?@sN*L^?IldEl_#uEVrDwY!Ydl0pAh yx;<0dZr2qW$Q(nkM4{9YmtMnq$9yeEV}h@{ERj~ug%@Wj` - + diff --git a/app/src/main/res/values-v31/themes.xml b/app/src/main/res/values-v31/themes.xml index 70e8a9d..e46e021 100644 --- a/app/src/main/res/values-v31/themes.xml +++ b/app/src/main/res/values-v31/themes.xml @@ -1,10 +1,11 @@ diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 7b56210..4ff5eff 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,10 +1,11 @@ - #0B1220 + #000000