feat: HA/Google edit, full-page event detail, KW+zigzag divider, HSV picker, splash

- 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 <noreply@anthropic.com>
This commit is contained in:
Guido Schmit
2026-05-31 14:19:56 +02:00
parent 608580fc7e
commit b8eb6597ec
18 changed files with 738 additions and 315 deletions

View File

@@ -14,6 +14,7 @@
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:screenOrientation="portrait"
android:theme="@style/Theme.Calendarr"> android:theme="@style/Theme.Calendarr">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />

View File

@@ -305,31 +305,56 @@ class CalendarRepository @Inject constructor(
calendarDbId: Int, title: String, start: Instant, end: Instant, calendarDbId: Int, title: String, start: Instant, end: Instant,
isAllDay: Boolean, location: String, description: String, isAllDay: Boolean, location: String, description: String,
) = guarded { ) = guarded {
api.createGoogleEvent( api.createGoogleEvent(simpleEventBody("calendar_db_id", calendarDbId, title, start, end, isAllDay, location, description))
jsonBody( .ensureSuccess()
"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()
} }
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( suspend fun createHAEvent(
calendarId: Int, title: String, start: Instant, end: Instant, calendarId: Int, title: String, start: Instant, end: Instant,
isAllDay: Boolean, location: String, description: String, isAllDay: Boolean, location: String, description: String,
) = guarded { ) = guarded {
api.createHAEvent( api.createHAEvent(simpleEventBody("calendar_id", calendarId, title, start, end, isAllDay, location, description))
jsonBody( .ensureSuccess()
"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, suspend fun updateHAEvent(
) calendarId: Int, uid: String, title: String, start: Instant, end: Instant,
).ensureSuccess() 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) = suspend fun deleteHAEvent(calendarId: Int, uid: String) =
guarded { api.deleteHAEvent(calendarId, uid).ensureSuccess() } 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( private fun eventBody(
calendarId: Int?, title: String, start: Instant, end: Instant, calendarId: Int?, title: String, start: Instant, end: Instant,
isAllDay: Boolean, location: String, description: String, color: String?, isAllDay: Boolean, location: String, description: String, color: String?,

View File

@@ -170,10 +170,30 @@ interface CalendarrApi {
@POST("api/google/events") @POST("api/google/events")
suspend fun createGoogleEvent(@Body body: RequestBody): Response<ResponseBody> suspend fun createGoogleEvent(@Body body: RequestBody): Response<ResponseBody>
@PUT("api/google/events/{gcalDbId}/{eventId}")
suspend fun updateGoogleEvent(
@Path("gcalDbId") gcalDbId: Int,
@Path("eventId") eventId: String,
@Body body: RequestBody,
): Response<ResponseBody>
@HTTP(method = "DELETE", path = "api/google/events/{gcalDbId}/{eventId}", hasBody = false)
suspend fun deleteGoogleEvent(
@Path("gcalDbId") gcalDbId: Int,
@Path("eventId") eventId: String,
): Response<ResponseBody>
@POST("api/homeassistant/events") @POST("api/homeassistant/events")
suspend fun createHAEvent(@Body body: RequestBody): Response<ResponseBody> suspend fun createHAEvent(@Body body: RequestBody): Response<ResponseBody>
@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<ResponseBody>
@HTTP(method = "DELETE", path = "api/homeassistant/events/{calendarId}/{uid}", hasBody = false)
suspend fun deleteHAEvent( suspend fun deleteHAEvent(
@Path("calendarId") calendarId: Int, @Path("calendarId") calendarId: Int,
@Path("uid") uid: String, @Path("uid") uid: String,

View File

@@ -5,8 +5,12 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue 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.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.scarriffle.calendarr.ui.auth.LoginScreen import com.scarriffle.calendarr.ui.auth.LoginScreen
@@ -19,12 +23,22 @@ fun CalendarrRoot(vm: MainViewModel = hiltViewModel()) {
val route by vm.route.collectAsState() val route by vm.route.collectAsState()
val settings by vm.settings.collectAsState() val settings by vm.settings.collectAsState()
var showSplash by remember { mutableStateOf(true) }
LaunchedEffect(Unit) {
kotlinx.coroutines.delay(1200)
showSplash = false
}
CalendarrTheme(settings) { CalendarrTheme(settings) {
CompositionLocalProvider( CompositionLocalProvider(
LocalLang provides L10n.resolved(settings.language), LocalLang provides L10n.resolved(settings.language),
LocalAppSettings provides settings, LocalAppSettings provides settings,
) { ) {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
if (showSplash) {
SplashScreen()
return@Surface
}
when (route) { when (route) {
AppRoute.SETUP -> ServerSetupScreen(onConfigured = vm::onServerConfigured) AppRoute.SETUP -> ServerSetupScreen(onConfigured = vm::onServerConfigured)
AppRoute.LOGIN -> LoginScreen( AppRoute.LOGIN -> LoginScreen(

View File

@@ -95,6 +95,7 @@ object L10n {
"event.color" to "Terminfarbe", "event.reset_color" to "Zurücksetzen", "event.color" to "Terminfarbe", "event.reset_color" to "Zurücksetzen",
"event.edit_title" to "Termin bearbeiten", "event.new_title" to "Neuer Termin", "event.edit_title" to "Termin bearbeiten", "event.new_title" to "Neuer Termin",
"event.copy_title" to "Termin kopieren", "event.copy_to" to "In Kalender kopieren", "event.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.save" to "Sichern", "event.add" to "Hinzufügen",
"event.delete_confirm" to "Diesen Termin löschen?", "event.delete_confirm" to "Diesen Termin löschen?",
"accounts.title" to "Konten", "accounts.loading" to "Lade Konten…", "accounts.title" to "Konten", "accounts.loading" to "Lade Konten…",
@@ -195,6 +196,7 @@ object L10n {
"event.color" to "Event color", "event.reset_color" to "Reset", "event.color" to "Event color", "event.reset_color" to "Reset",
"event.edit_title" to "Edit event", "event.new_title" to "New event", "event.edit_title" to "Edit event", "event.new_title" to "New event",
"event.copy_title" to "Copy event", "event.copy_to" to "Copy to calendar", "event.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.save" to "Save", "event.add" to "Add",
"event.delete_confirm" to "Delete this event?", "event.delete_confirm" to "Delete this event?",
"accounts.title" to "Accounts", "accounts.loading" to "Loading accounts…", "accounts.title" to "Accounts", "accounts.loading" to "Loading accounts…",

View File

@@ -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),
)
}
}

View File

@@ -49,6 +49,22 @@ fun weekdayLabels(mondayFirst: Boolean, lang: String): List<String> {
fun timeLabel(instant: Instant, lang: String): String = fun timeLabel(instant: Instant, lang: String): String =
DateTimeFormatter.ofPattern("HH:mm", L10n.locale(lang)).withZone(zone).format(instant) 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 localTime(instant: Instant): LocalTime = LocalTime.ofInstant(instant, zone)
fun localDate(instant: Instant): LocalDate = LocalDate.ofInstant(instant, zone) fun localDate(instant: Instant): LocalDate = LocalDate.ofInstant(instant, zone)

View File

@@ -2,9 +2,13 @@ package com.scarriffle.calendarr.ui.calendar
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ChevronLeft 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.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -35,15 +39,17 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.scarriffle.calendarr.domain.model.AppSettings import com.scarriffle.calendarr.domain.model.AppSettings
import com.scarriffle.calendarr.domain.model.CalEvent import com.scarriffle.calendarr.domain.model.CalEvent
import com.scarriffle.calendarr.domain.model.CalViewType import com.scarriffle.calendarr.domain.model.CalViewType
import com.scarriffle.calendarr.ui.LocalLang import com.scarriffle.calendarr.ui.LocalLang
import com.scarriffle.calendarr.ui.accounts.AccountsScreen 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.event.EventEditorSheet
import com.scarriffle.calendarr.ui.menu.MenuSheet import com.scarriffle.calendarr.ui.menu.MenuSheet
import com.scarriffle.calendarr.ui.profile.ProfileScreen import com.scarriffle.calendarr.ui.profile.ProfileScreen
@@ -77,57 +83,36 @@ fun CalendarScreen(
// Continuous month scrolling // Continuous month scrolling
val monthListState = rememberLazyListState() val monthListState = rememberLazyListState()
var todaySignal by remember { mutableIntStateOf(0) } var todaySignal by remember { mutableIntStateOf(0) }
var monthJumpSignal by remember { mutableIntStateOf(0) }
var monthJumpTarget by remember { mutableStateOf<LocalDate?>(null) }
var visibleMonth by remember { mutableStateOf(state.currentDate) } var visibleMonth by remember { mutableStateOf(state.currentDate) }
val isMonth = state.viewType == CalViewType.MONTH val isMonth = state.viewType == CalViewType.MONTH
val barTitle = if (isMonth) titleForView(CalViewType.MONTH, visibleMonth, lang) val barTitle = if (isMonth) titleForView(CalViewType.MONTH, visibleMonth, lang)
else titleForView(state.viewType, state.currentDate, 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( Scaffold(
topBar = { topBar = {
TopAppBar( CompactTopBar(
title = { title = barTitle,
Text(barTitle, maxLines = 1, overflow = TextOverflow.Ellipsis) viewType = state.viewType,
}, viewMenuOpen = viewMenuOpen,
navigationIcon = { onMenu = { showMenu = true },
IconButton(onClick = { showMenu = true }) { onPrev = { goPrev() },
Icon(Icons.Filled.Menu, contentDescription = tr("nav.menu")) onToday = { goToday() },
} onNext = { goNext() },
}, onFilter = { showFilter = true },
actions = { onViewMenuToggle = { viewMenuOpen = it },
if (!isMonth) { onSelectView = { vm.setViewType(it) },
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)
},
)
}
}
}
},
) )
}, },
floatingActionButton = { floatingActionButton = {
@@ -153,6 +138,8 @@ fun CalendarScreen(
vm = vm, vm = vm,
monthListState = monthListState, monthListState = monthListState,
scrollToTodaySignal = todaySignal, scrollToTodaySignal = todaySignal,
monthJumpSignal = monthJumpSignal,
monthJumpTarget = monthJumpTarget,
onVisibleMonthChange = { visibleMonth = it }, onVisibleMonthChange = { visibleMonth = it },
onEventClick = { detailEvent = it }, onEventClick = { detailEvent = it },
onDayClick = { date -> vm.goToDate(date, CalViewType.DAY) }, onDayClick = { date -> vm.goToDate(date, CalViewType.DAY) },
@@ -186,21 +173,21 @@ fun CalendarScreen(
} }
detailEvent?.let { ev -> detailEvent?.let { ev ->
EventDetailSheet( EventDetailScreen(
event = ev, event = ev,
onDismiss = { detailEvent = null }, onClose = { detailEvent = null },
onEdit = { onEdit = {
detailEvent = null detailEvent = null
editor = EditorRequest(ev, localDate(ev.startDate)) editor = EditorRequest(ev, localDate(ev.startDate))
}, },
onDelete = {
vm.deleteEvent(ev) {}
detailEvent = null
},
onCopy = { onCopy = {
detailEvent = null detailEvent = null
editor = EditorRequest(existing = null, date = localDate(ev.startDate), prefill = ev) 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, vm: CalendarViewModel,
monthListState: androidx.compose.foundation.lazy.LazyListState, monthListState: androidx.compose.foundation.lazy.LazyListState,
scrollToTodaySignal: Int, scrollToTodaySignal: Int,
monthJumpSignal: Int,
monthJumpTarget: LocalDate?,
onVisibleMonthChange: (LocalDate) -> Unit, onVisibleMonthChange: (LocalDate) -> Unit,
onEventClick: (CalEvent) -> Unit, onEventClick: (CalEvent) -> Unit,
onDayClick: (LocalDate) -> Unit, onDayClick: (LocalDate) -> Unit,
@@ -249,6 +238,8 @@ private fun CalendarBody(
vm = vm, vm = vm,
listState = monthListState, listState = monthListState,
scrollToTodaySignal = scrollToTodaySignal, scrollToTodaySignal = scrollToTodaySignal,
monthJumpSignal = monthJumpSignal,
monthJumpTarget = monthJumpTarget,
onVisibleMonthChange = onVisibleMonthChange, onVisibleMonthChange = onVisibleMonthChange,
onDayClick = onDayClick, onDayClick = onDayClick,
onDayLongPress = onDayLongPress, 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. */ /** Distinct calendars currently present in the cache, for the filter sheet. */
private fun allKnownCalendars(vm: CalendarViewModel): List<CalendarFilterEntry> { private fun allKnownCalendars(vm: CalendarViewModel): List<CalendarFilterEntry> {
val st = vm.state.value val st = vm.state.value

View File

@@ -295,6 +295,8 @@ class CalendarViewModel @Inject constructor(
when (existing.source) { when (existing.source) {
"local" -> repository.updateLocalEvent(existing.id, title, start, end, isAllDay, location, description, color) "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) "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 -> createForSource(calendar, title, start, end, isAllDay, location, description, color)
} }
} else { } else {
@@ -325,6 +327,7 @@ class CalendarViewModel @Inject constructor(
"local" -> repository.deleteLocalEvent(event.id) "local" -> repository.deleteLocalEvent(event.id)
"caldav" -> repository.deleteCalDAVEvent(event.id, event.url, calendarNumericId(event)) "caldav" -> repository.deleteCalDAVEvent(event.id, event.url, calendarNumericId(event))
"homeassistant" -> repository.deleteHAEvent(calendarNumericId(event) ?: 0, event.id) "homeassistant" -> repository.deleteHAEvent(calendarNumericId(event) ?: 0, event.id)
"google" -> repository.deleteGoogleEvent(calendarNumericId(event) ?: 0, event.id)
else -> Unit else -> Unit
} }
} }

View File

@@ -5,18 +5,19 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Divider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -27,6 +28,9 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.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.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow 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.domain.model.CalEvent
import com.scarriffle.calendarr.ui.LocalAppSettings import com.scarriffle.calendarr.ui.LocalAppSettings
import com.scarriffle.calendarr.ui.LocalLang import com.scarriffle.calendarr.ui.LocalLang
import com.scarriffle.calendarr.ui.tr
import com.scarriffle.calendarr.util.colorFromHex import com.scarriffle.calendarr.util.colorFromHex
import com.scarriffle.calendarr.util.contrastingTextColor import com.scarriffle.calendarr.util.contrastingTextColor
import java.time.DayOfWeek
import java.time.LocalDate import java.time.LocalDate
import java.time.format.TextStyle import java.time.format.TextStyle
import java.time.temporal.ChronoUnit import java.time.temporal.ChronoUnit
import java.time.temporal.IsoFields
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
private const val MONTHS_BACK = 18L private const val MONTHS_BACK = 18L
private const val MONTHS_AHEAD = 18L private const val MONTHS_AHEAD = 18L
private val ROW_HEIGHT = 82.dp
/** private enum class DividerEdge { NONE, TOP, BOTTOM }
* Continuous, vertically scrolling month calendar (matches the iOS app).
* There are no prev/next buttons — the user scrolls through weeks and the /** Continuous, vertically scrolling month calendar (matches the iOS app). */
* top-bar title follows the currently visible month.
*/
@Composable @Composable
fun MonthView( fun MonthView(
state: CalendarUiState, state: CalendarUiState,
vm: CalendarViewModel, vm: CalendarViewModel,
listState: LazyListState, listState: LazyListState,
scrollToTodaySignal: Int, scrollToTodaySignal: Int,
monthJumpSignal: Int,
monthJumpTarget: LocalDate?,
onVisibleMonthChange: (LocalDate) -> Unit, onVisibleMonthChange: (LocalDate) -> Unit,
onDayClick: (LocalDate) -> Unit, onDayClick: (LocalDate) -> Unit,
onDayLongPress: (LocalDate) -> Unit, onDayLongPress: (LocalDate) -> Unit,
onEventClick: (CalEvent) -> Unit, onEventClick: (CalEvent) -> Unit,
) { ) {
val lang = LocalLang.current val lang = LocalLang.current
val settings = LocalAppSettings.current
val mondayFirst = state.weekStartsOnMonday val mondayFirst = state.weekStartsOnMonday
val today = LocalDate.now() 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) { val firstVisible = remember(mondayFirst) {
startOfWeek(today.withDayOfMonth(1).minusMonths(MONTHS_BACK), mondayFirst) startOfWeek(today.withDayOfMonth(1).minusMonths(MONTHS_BACK), mondayFirst)
} }
@@ -75,19 +89,20 @@ fun MonthView(
val weekCount = remember(firstVisible, end) { val weekCount = remember(firstVisible, end) {
(ChronoUnit.WEEKS.between(firstVisible, end).toInt() + 1).coerceAtLeast(1) (ChronoUnit.WEEKS.between(firstVisible, end).toInt() + 1).coerceAtLeast(1)
} }
val todayIndex = remember(firstVisible) { fun weekIndexOf(date: LocalDate): Int =
ChronoUnit.WEEKS.between(firstVisible, startOfWeek(today, mondayFirst)).toInt() ChronoUnit.WEEKS.between(firstVisible, startOfWeek(date, mondayFirst)).toInt().coerceIn(0, weekCount - 1)
}
// Initial scroll to today's week. val todayIndex = remember(firstVisible) { weekIndexOf(today) }
LaunchedEffect(Unit) {
listState.scrollToItem((todayIndex - 1).coerceAtLeast(0)) LaunchedEffect(Unit) { listState.scrollToItem((todayIndex - 1).coerceAtLeast(0)) }
}
// "Today" button.
LaunchedEffect(scrollToTodaySignal) { LaunchedEffect(scrollToTodaySignal) {
if (scrollToTodaySignal > 0) listState.animateScrollToItem((todayIndex - 1).coerceAtLeast(0)) if (scrollToTodaySignal > 0) listState.animateScrollToItem((todayIndex - 1).coerceAtLeast(0))
} }
// Track visible month + trigger on-demand loads (only when the month changes). LaunchedEffect(monthJumpSignal) {
if (monthJumpSignal > 0 && monthJumpTarget != null) {
listState.animateScrollToItem(weekIndexOf(monthJumpTarget.withDayOfMonth(1)))
}
}
LaunchedEffect(listState, weekCount) { LaunchedEffect(listState, weekCount) {
snapshotFlow { listState.firstVisibleItemIndex } snapshotFlow { listState.firstVisibleItemIndex }
.map { firstVisible.plusWeeks(it.toLong()).plusDays(3).withDayOfMonth(1) } .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) } val eventsByDay = remember(state.events) { buildEventsByDay(state.events) }
Column(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) {
// Fixed weekday header Row(Modifier.fillMaxWidth().padding(vertical = 3.dp)) {
Row(Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
weekdayLabels(mondayFirst, lang).forEach { label -> weekdayLabels(mondayFirst, lang).forEach { label ->
Text( Text(
label, label,
@@ -115,16 +127,19 @@ fun MonthView(
) )
} }
} }
Divider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f))
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) { LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
items(weekCount) { index -> items(weekCount) { index ->
val weekStart = firstVisible.plusWeeks(index.toLong())
WeekRow( WeekRow(
weekStart = weekStart, weekStart = firstVisible.plusWeeks(index.toLong()),
today = today, today = today,
eventsByDay = eventsByDay, eventsByDay = eventsByDay,
lang = lang, lang = lang,
dividerColor = dividerColor,
gridColor = gridColor,
labelColor = labelColor,
secondaryText = secondaryText,
todayColor = colorFromHex(settings.todayColor),
onDayClick = onDayClick, onDayClick = onDayClick,
onDayLongPress = onDayLongPress, onDayLongPress = onDayLongPress,
onEventClick = onEventClick, onEventClick = onEventClick,
@@ -134,37 +149,67 @@ fun MonthView(
} }
} }
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
private fun WeekRow( private fun WeekRow(
weekStart: LocalDate, weekStart: LocalDate,
today: LocalDate, today: LocalDate,
eventsByDay: Map<LocalDate, List<CalEvent>>, eventsByDay: Map<LocalDate, List<CalEvent>>,
lang: String, lang: String,
dividerColor: Color,
gridColor: Color,
labelColor: Color,
secondaryText: Color,
todayColor: Color,
onDayClick: (LocalDate) -> Unit, onDayClick: (LocalDate) -> Unit,
onDayLongPress: (LocalDate) -> Unit, onDayLongPress: (LocalDate) -> Unit,
onEventClick: (CalEvent) -> Unit, onEventClick: (CalEvent) -> Unit,
) { ) {
val days = (0 until 7).map { weekStart.plusDays(it.toLong()) } val days = (0 until 7).map { weekStart.plusDays(it.toLong()) }
Row( val boundaryCol = (1 until 7).firstOrNull { days[it].dayOfMonth == 1 }
Modifier val rowStartsNewMonth = days[0].dayOfMonth == 1
.fillMaxWidth() val cwLabel = tr("cal.cw")
.height(78.dp),
) { BoxWithConstraints(Modifier.fillMaxWidth().height(ROW_HEIGHT)) {
days.forEach { day -> val cellW = maxWidth / 7
DayCell( Row(Modifier.fillMaxSize()) {
day = day, days.forEachIndexed { idx, day ->
isToday = day == today, val edge = when {
events = eventsByDay[day] ?: emptyList(), boundaryCol != null -> if (idx < boundaryCol) DividerEdge.BOTTOM else DividerEdge.TOP
lang = lang, rowStartsNewMonth -> DividerEdge.TOP
onClick = { onDayClick(day) }, else -> DividerEdge.NONE
onLongClick = { onDayLongPress(day) }, }
onEventClick = onEventClick, DayCell(
modifier = Modifier.weight(1f).fillMaxSize(), 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) @OptIn(ExperimentalFoundationApi::class)
@@ -172,54 +217,85 @@ private fun WeekRow(
private fun DayCell( private fun DayCell(
day: LocalDate, day: LocalDate,
isToday: Boolean, isToday: Boolean,
isMonday: Boolean,
weekNumber: Int,
cwLabel: String,
events: List<CalEvent>, events: List<CalEvent>,
lang: String, lang: String,
edge: DividerEdge,
dividerColor: Color,
gridColor: Color,
labelColor: Color,
secondaryText: Color,
todayColor: Color,
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: () -> Unit, onLongClick: () -> Unit,
onEventClick: (CalEvent) -> Unit, onEventClick: (CalEvent) -> Unit,
modifier: Modifier = Modifier, 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 isFirst = day.dayOfMonth == 1
val onBg = MaterialTheme.colorScheme.onBackground
Column( Box(
modifier = modifier modifier = modifier
.combinedClickable(onClick = onClick, onLongClick = onLongClick) .drawBehind {
.padding(horizontal = 1.dp, vertical = 2.dp), val gridW = 0.5.dp.toPx()
horizontalAlignment = Alignment.CenterHorizontally, val divW = 1.5.dp.toPx()
) { // top border (thick divider on a month boundary, otherwise thin grid)
Row(verticalAlignment = Alignment.CenterVertically) { if (edge == DividerEdge.TOP) {
if (isFirst) { drawLine(dividerColor, Offset(0f, 0f), Offset(size.width, 0f), divW)
Text( } else {
day.month.getDisplayName(TextStyle.SHORT, com.scarriffle.calendarr.ui.L10n.locale(lang)), drawLine(gridColor, Offset(0f, 0f), Offset(size.width, 0f), gridW)
fontSize = 8.sp, }
color = monthLabelColor, // bottom border only when the month ends inside this row
modifier = Modifier.padding(end = 2.dp), 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) { .combinedClickable(onClick = onClick, onLongClick = onLongClick)
if (isToday) { .padding(horizontal = 1.dp),
Box( ) {
Modifier Column(
.height(18.dp) Modifier.fillMaxSize().padding(top = 3.dp),
.width(18.dp) horizontalAlignment = Alignment.CenterHorizontally,
.clip(RoundedCornerShape(9.dp)) ) {
.background(todayColor), 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( Box(contentAlignment = Alignment.Center) {
"${day.dayOfMonth}", if (isToday) {
fontSize = 11.sp, Box(Modifier.height(18.dp).width(18.dp).clip(RoundedCornerShape(9.dp)).background(todayColor))
fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal, }
color = if (isToday) todayColor.contrastingTextColor() else MaterialTheme.colorScheme.onBackground, 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) } } // Calendar week number, bottom-right of the Monday cell.
if (events.size > 3) { if (isMonday) {
Text("+${events.size - 3}", fontSize = 8.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) 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<CalEvent>): Map<LocalDate, List<CalEve
val map = HashMap<LocalDate, MutableList<CalEvent>>() val map = HashMap<LocalDate, MutableList<CalEvent>>()
for (ev in events) { for (ev in events) {
val first = localDate(ev.startDate) 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) val last = localDate(ev.endDate.minusSeconds(1)).coerceAtLeast(first)
var d = first var d = first
var guard = 0 var guard = 0

View File

@@ -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") } },
)
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -46,9 +46,10 @@ import androidx.hilt.navigation.compose.hiltViewModel
import com.scarriffle.calendarr.domain.model.AppSettings import com.scarriffle.calendarr.domain.model.AppSettings
import com.scarriffle.calendarr.domain.model.CalViewType import com.scarriffle.calendarr.domain.model.CalViewType
import com.scarriffle.calendarr.ui.LocalAppSettings import com.scarriffle.calendarr.ui.LocalAppSettings
import com.scarriffle.calendarr.ui.components.ColorPickerDialog
import com.scarriffle.calendarr.ui.tr import com.scarriffle.calendarr.ui.tr
import com.scarriffle.calendarr.util.colorFromHex import com.scarriffle.calendarr.util.colorFromHex
import com.scarriffle.calendarr.util.contrastingTextColor import com.scarriffle.calendarr.util.toHex
private val PALETTE = listOf( private val PALETTE = listOf(
"#4285f4", "#ea4335", "#34a853", "#fbbc05", "#46bdc6", "#9c27b0", "#ff7043", "#7090c0", "#4285f4", "#ea4335", "#34a853", "#fbbc05", "#46bdc6", "#9c27b0", "#ff7043", "#7090c0",
@@ -116,9 +117,11 @@ fun SettingsScreen(
Divider(Modifier.padding(vertical = 16.dp)) Divider(Modifier.padding(vertical = 16.dp))
Section(tr("settings.colors")) Section(tr("settings.colors"))
ColorChooser(tr("settings.color.primary"), settings.primaryColor) { update(settings.copy(primaryColor = it)) } ColorRow(tr("settings.color.primary"), settings.primaryColor) { update(settings.copy(primaryColor = it)) }
ColorChooser(tr("settings.color.accent"), settings.accentColor) { update(settings.copy(accentColor = it)) } ColorRow(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.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)) Divider(Modifier.padding(vertical = 16.dp))
Section(tr("settings.hourheight")) Section(tr("settings.hourheight"))
@@ -182,35 +185,24 @@ private fun ChipRow(options: List<Pair<String, String>>, selected: String, onSel
} }
} }
@OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
private fun ColorChooser(label: String, current: String, onPick: (String) -> Unit) { private fun ColorRow(label: String, current: String, onPick: (String) -> Unit) {
// Show the user's current colour even if it isn't one of the presets. var showPicker by remember { mutableStateOf(false) }
val swatches = remember(current) { Row(
(if (PALETTE.any { it.equals(current, ignoreCase = true) }) PALETTE else listOf(current) + PALETTE) 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)) { if (showPicker) {
Row(verticalAlignment = Alignment.CenterVertically) { ColorPickerDialog(
Box(Modifier.size(18.dp).clip(CircleShape).background(colorFromHex(current))) initial = current,
Spacer(Modifier.size(8.dp)) title = label,
Text(label, style = MaterialTheme.typography.bodyMedium) onDismiss = { showPicker = false },
} onConfirm = { showPicker = false; onPick(it) },
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))
}
}
}
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

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

View File

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

View File

@@ -1,10 +1,11 @@
<resources> <resources>
<color name="splash_bg">#0B1220</color> <color name="splash_bg">#000000</color>
<style name="Theme.Calendarr" parent="Theme.Material3.DayNight.NoActionBar"> <style name="Theme.Calendarr" parent="Theme.Material3.DayNight.NoActionBar">
<!-- Dark window background avoids a white flash before Compose draws. --> <!-- Black window/system bars (matches the app background). -->
<item name="android:windowBackground">@color/splash_bg</item> <item name="android:windowBackground">@color/splash_bg</item>
<item name="android:statusBarColor">@color/splash_bg</item> <item name="android:statusBarColor">@android:color/black</item>
<item name="android:navigationBarColor">@color/splash_bg</item> <item name="android:navigationBarColor">@android:color/black</item>
<item name="android:windowLightStatusBar">false</item>
</style> </style>
</resources> </resources>