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 0000000..a88fa5b Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_splash_logo.png differ diff --git a/app/src/main/res/drawable/splash_icon.xml b/app/src/main/res/drawable/splash_icon.xml index a72e494..0073872 100644 --- a/app/src/main/res/drawable/splash_icon.xml +++ b/app/src/main/res/drawable/splash_icon.xml @@ -1,5 +1,5 @@ - + 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