feat: full Compose UI (auth, calendar, events, accounts, settings, profile)
- Root navigation (server setup -> login+2FA -> calendar) with settings-driven theme - CalendarViewModel mirroring iOS CalendarStore: range-based caching, background prefetch, hidden/banished calendar filters, event CRUD - Five calendar views: Month, Week, Day, Quarter, Agenda (shared time grid) - Event detail + editor sheets (platform date/time pickers, color override, writable-calendar picker) across local/caldav/google/homeassistant - Calendar filter sheet (per-calendar show/hide) - Accounts screen: add/delete CalDAV, local, iCal, Home Assistant; list Google - Settings: view/week-start/language/colors/contrast/hour-height/cache range, synced to server - Profile: email, password change, 2FA enable/disable - gradlew build (lint + tests + assemble) passes Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
676b7ee2c6
commit
aff6cef493
@@ -0,0 +1,109 @@
|
||||
package com.scarriffle.calendarr.ui.calendar
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
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.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.tr
|
||||
import com.scarriffle.calendarr.util.colorFromHex
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@Composable
|
||||
fun AgendaView(
|
||||
state: CalendarUiState,
|
||||
vm: CalendarViewModel,
|
||||
onEventClick: (CalEvent) -> Unit,
|
||||
) {
|
||||
val lang = LocalLang.current
|
||||
val today = LocalDate.now()
|
||||
val days = (0 until 90).map { today.plusDays(it.toLong()) }
|
||||
.map { it to vm.eventsOn(it, state.events) }
|
||||
.filter { it.second.isNotEmpty() }
|
||||
|
||||
if (days.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(tr("cal.no_events_title"), style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
tr("cal.no_events_body"),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val headerFmt = DateTimeFormatter.ofPattern("EEEE, d. MMMM", com.scarriffle.calendarr.ui.L10n.locale(lang))
|
||||
|
||||
LazyColumn(Modifier.fillMaxSize().padding(horizontal = 12.dp)) {
|
||||
days.forEach { (date, events) ->
|
||||
item(key = "h-$date") {
|
||||
Text(
|
||||
headerFmt.format(date).replaceFirstChar { it.uppercase() },
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = if (date == today) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier.padding(top = 16.dp, bottom = 6.dp),
|
||||
)
|
||||
}
|
||||
items(events, key = { "${date}-${it.id}" }) { ev ->
|
||||
AgendaRow(ev, lang, onClick = { onEventClick(ev) })
|
||||
}
|
||||
}
|
||||
item { Spacer(Modifier.height(80.dp)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgendaRow(event: CalEvent, lang: String, onClick: () -> Unit) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(Modifier.size(10.dp).clip(CircleShape).background(colorFromHex(event.effectiveColor)))
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(event.title, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium)
|
||||
if (event.location.isNotBlank()) {
|
||||
Text(event.location, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
eventTimeRange(event, lang),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.scarriffle.calendarr.ui.calendar
|
||||
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.scarriffle.calendarr.ui.tr
|
||||
import com.scarriffle.calendarr.util.colorFromHex
|
||||
|
||||
data class CalendarFilterEntry(val key: String, val name: String, val color: String)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CalendarFilterSheet(
|
||||
events: List<CalendarFilterEntry>,
|
||||
vm: CalendarViewModel,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val state by vm.state.collectAsState()
|
||||
|
||||
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||
Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(tr("filter.title"), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
|
||||
Row {
|
||||
TextButton(onClick = { vm.setHiddenCalendars(emptySet()) }) { Text(tr("filter.show_all")) }
|
||||
TextButton(onClick = {
|
||||
vm.setHiddenCalendars(events.map { it.key }.toSet())
|
||||
}) { Text(tr("filter.hide_all")) }
|
||||
}
|
||||
}
|
||||
if (events.isEmpty()) {
|
||||
Text(
|
||||
tr("filter.empty"),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = 24.dp),
|
||||
)
|
||||
}
|
||||
events.forEach { entry ->
|
||||
val visible = entry.key !in state.hiddenKeys
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(Modifier.size(14.dp).clip(CircleShape).background(colorFromHex(entry.color)))
|
||||
Text(
|
||||
entry.name,
|
||||
modifier = Modifier.weight(1f).padding(start = 12.dp),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
Switch(
|
||||
checked = visible,
|
||||
onCheckedChange = { vm.setCalendarHidden(entry.key, hidden = !it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(Modifier.padding(bottom = 24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.scarriffle.calendarr.ui.calendar
|
||||
|
||||
import com.scarriffle.calendarr.domain.model.CalEvent
|
||||
import com.scarriffle.calendarr.domain.model.CalViewType
|
||||
import com.scarriffle.calendarr.ui.L10n
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.TextStyle
|
||||
|
||||
private val zone: ZoneId = ZoneId.systemDefault()
|
||||
|
||||
fun titleForView(viewType: CalViewType, date: LocalDate, lang: String): String {
|
||||
val loc = L10n.locale(lang)
|
||||
return when (viewType) {
|
||||
CalViewType.MONTH ->
|
||||
DateTimeFormatter.ofPattern("LLLL yyyy", loc).format(date)
|
||||
.replaceFirstChar { it.uppercase(loc) }
|
||||
CalViewType.QUARTER -> {
|
||||
val fmt = DateTimeFormatter.ofPattern("LLL yyyy", loc)
|
||||
"${fmt.format(date)} – ${fmt.format(date.plusMonths(2))}"
|
||||
}
|
||||
CalViewType.WEEK -> {
|
||||
val start = date
|
||||
val fmt = DateTimeFormatter.ofPattern("d. MMM", loc)
|
||||
val endFmt = DateTimeFormatter.ofPattern("d. MMM yyyy", loc)
|
||||
"${fmt.format(start)} – ${endFmt.format(start.plusDays(6))}"
|
||||
}
|
||||
CalViewType.DAY ->
|
||||
DateTimeFormatter.ofPattern("EEEE, d. MMMM yyyy", loc).format(date)
|
||||
CalViewType.AGENDA -> L10n.t("view.agenda", lang)
|
||||
}
|
||||
}
|
||||
|
||||
fun weekdayLabels(mondayFirst: Boolean, lang: String): List<String> {
|
||||
val loc = L10n.locale(lang)
|
||||
val order = if (mondayFirst) {
|
||||
listOf(1, 2, 3, 4, 5, 6, 7)
|
||||
} else {
|
||||
listOf(7, 1, 2, 3, 4, 5, 6)
|
||||
}
|
||||
return order.map {
|
||||
java.time.DayOfWeek.of(it).getDisplayName(TextStyle.SHORT, loc).take(2)
|
||||
}
|
||||
}
|
||||
|
||||
fun timeLabel(instant: Instant, lang: String): String =
|
||||
DateTimeFormatter.ofPattern("HH:mm", L10n.locale(lang)).withZone(zone).format(instant)
|
||||
|
||||
fun localTime(instant: Instant): LocalTime = LocalTime.ofInstant(instant, zone)
|
||||
|
||||
fun localDate(instant: Instant): LocalDate = LocalDate.ofInstant(instant, zone)
|
||||
|
||||
/** Minutes-from-midnight of the instant in the local zone. */
|
||||
fun minutesOfDay(instant: Instant): Int {
|
||||
val t = localTime(instant)
|
||||
return t.hour * 60 + t.minute
|
||||
}
|
||||
|
||||
fun eventTimeRange(event: CalEvent, lang: String): String {
|
||||
if (event.isAllDay) return L10n.t("cal.allday", lang)
|
||||
return "${timeLabel(event.startDate, lang)} – ${timeLabel(event.endDate, lang)}"
|
||||
}
|
||||
|
||||
/** Full human-readable date (+time) range for the detail sheet. */
|
||||
fun eventDateRange(event: CalEvent, lang: String): String {
|
||||
val loc = L10n.locale(lang)
|
||||
val dateFmt = DateTimeFormatter.ofPattern("EEE, d. MMM yyyy", loc)
|
||||
val startDate = localDate(event.startDate)
|
||||
val endDate = localDate(event.endDate)
|
||||
if (event.isAllDay) {
|
||||
// All-day end is exclusive on the server; show the inclusive last day.
|
||||
val lastDay = endDate.minusDays(1)
|
||||
return if (lastDay.isAfter(startDate)) {
|
||||
"${dateFmt.format(startDate)} – ${dateFmt.format(lastDay)}"
|
||||
} else {
|
||||
"${dateFmt.format(startDate)} · ${L10n.t("cal.allday", lang)}"
|
||||
}
|
||||
}
|
||||
return if (startDate == endDate) {
|
||||
"${dateFmt.format(startDate)}\n${timeLabel(event.startDate, lang)} – ${timeLabel(event.endDate, lang)}"
|
||||
} else {
|
||||
"${dateFmt.format(startDate)} ${timeLabel(event.startDate, lang)}\n– ${dateFmt.format(endDate)} ${timeLabel(event.endDate, lang)}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package com.scarriffle.calendarr.ui.calendar
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ChevronLeft
|
||||
import androidx.compose.material.icons.filled.ChevronRight
|
||||
import androidx.compose.material.icons.filled.FilterList
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Today
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
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.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.EventEditorSheet
|
||||
import com.scarriffle.calendarr.ui.menu.MenuSheet
|
||||
import com.scarriffle.calendarr.ui.profile.ProfileScreen
|
||||
import com.scarriffle.calendarr.ui.settings.SettingsScreen
|
||||
import com.scarriffle.calendarr.ui.tr
|
||||
import java.time.LocalDate
|
||||
|
||||
private enum class Overlay { NONE, PROFILE, SETTINGS, ACCOUNTS }
|
||||
|
||||
data class EditorRequest(val existing: CalEvent?, val date: LocalDate)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CalendarScreen(
|
||||
onLogout: () -> Unit,
|
||||
onSwitchServer: () -> Unit,
|
||||
onSettingsChanged: (AppSettings) -> Unit,
|
||||
onSettingsSynced: () -> Unit,
|
||||
vm: CalendarViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by vm.state.collectAsState()
|
||||
val lang = LocalLang.current
|
||||
|
||||
var viewMenuOpen by remember { mutableStateOf(false) }
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
var showFilter by remember { mutableStateOf(false) }
|
||||
var detailEvent by remember { mutableStateOf<CalEvent?>(null) }
|
||||
var editor by remember { mutableStateOf<EditorRequest?>(null) }
|
||||
var overlay by remember { mutableStateOf(Overlay.NONE) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
titleForView(state.viewType, state.currentDate, lang),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { showMenu = true }) {
|
||||
Icon(Icons.Filled.Menu, contentDescription = tr("nav.menu"))
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = vm::navigatePrev) {
|
||||
Icon(Icons.Filled.ChevronLeft, contentDescription = null)
|
||||
}
|
||||
IconButton(onClick = vm::moveToToday) {
|
||||
Icon(Icons.Filled.Today, contentDescription = tr("nav.today"))
|
||||
}
|
||||
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(onClick = { editor = EditorRequest(null, state.currentDate) }) {
|
||||
Icon(Icons.Filled.Add, contentDescription = tr("cal.new_event"))
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
Column(Modifier.fillMaxSize().padding(padding)) {
|
||||
if (state.isLoading || state.isBackgroundCaching) {
|
||||
LinearProgressIndicator(Modifier.fillMaxWidth())
|
||||
}
|
||||
state.error?.let { err ->
|
||||
ErrorBanner(err, onRetry = { vm.loadVisible(force = true) }, onDismiss = vm::clearError)
|
||||
}
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
CalendarBody(
|
||||
state = state,
|
||||
vm = vm,
|
||||
onEventClick = { detailEvent = it },
|
||||
onDayClick = { date -> vm.goToDate(date, CalViewType.DAY) },
|
||||
onEmptySlotClick = { date -> editor = EditorRequest(null, date) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Sheets ----
|
||||
|
||||
if (showMenu) {
|
||||
MenuSheet(
|
||||
isAdmin = false,
|
||||
onDismiss = { showMenu = false },
|
||||
onProfile = { showMenu = false; overlay = Overlay.PROFILE },
|
||||
onAppearance = { showMenu = false; overlay = Overlay.SETTINGS },
|
||||
onAccounts = { showMenu = false; overlay = Overlay.ACCOUNTS },
|
||||
onSync = { showMenu = false; vm.syncWithServer() },
|
||||
onLogout = { showMenu = false; onLogout() },
|
||||
onSwitchServer = { showMenu = false; onSwitchServer() },
|
||||
)
|
||||
}
|
||||
|
||||
if (showFilter) {
|
||||
CalendarFilterSheet(
|
||||
events = remember(state.events, state.hiddenKeys) { allKnownCalendars(vm) },
|
||||
vm = vm,
|
||||
onDismiss = { showFilter = false },
|
||||
)
|
||||
}
|
||||
|
||||
detailEvent?.let { ev ->
|
||||
EventDetailSheet(
|
||||
event = ev,
|
||||
onDismiss = { detailEvent = null },
|
||||
onEdit = {
|
||||
detailEvent = null
|
||||
editor = EditorRequest(ev, localDate(ev.startDate))
|
||||
},
|
||||
onDelete = {
|
||||
vm.deleteEvent(ev) {}
|
||||
detailEvent = null
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
editor?.let { req ->
|
||||
EventEditorSheet(
|
||||
request = req,
|
||||
writableCalendars = state.writableCalendars,
|
||||
onDismiss = { editor = null },
|
||||
onSave = { cal, title, start, end, allDay, location, desc, color ->
|
||||
vm.saveEvent(cal, req.existing, title, start, end, allDay, location, desc, color) { error ->
|
||||
if (error == null) editor = null
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
when (overlay) {
|
||||
Overlay.PROFILE -> ProfileScreen(onClose = { overlay = Overlay.NONE })
|
||||
Overlay.SETTINGS -> SettingsScreen(
|
||||
onClose = { overlay = Overlay.NONE },
|
||||
onSettingsChanged = onSettingsChanged,
|
||||
onSettingsSynced = onSettingsSynced,
|
||||
)
|
||||
Overlay.ACCOUNTS -> AccountsScreen(
|
||||
onClose = { overlay = Overlay.NONE },
|
||||
onChanged = { vm.loadWritableCalendars(); vm.syncWithServer() },
|
||||
)
|
||||
Overlay.NONE -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CalendarBody(
|
||||
state: CalendarUiState,
|
||||
vm: CalendarViewModel,
|
||||
onEventClick: (CalEvent) -> Unit,
|
||||
onDayClick: (LocalDate) -> Unit,
|
||||
onEmptySlotClick: (LocalDate) -> Unit,
|
||||
) {
|
||||
when (state.viewType) {
|
||||
CalViewType.MONTH -> MonthView(state, vm, onDayClick, onEventClick)
|
||||
CalViewType.WEEK -> WeekView(state, vm, onEventClick)
|
||||
CalViewType.DAY -> DayView(state, vm, onEventClick)
|
||||
CalViewType.QUARTER -> QuarterView(state, onDayClick)
|
||||
CalViewType.AGENDA -> AgendaView(state, vm, onEventClick)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorBanner(message: String, onRetry: () -> Unit, onDismiss: () -> Unit) {
|
||||
androidx.compose.material3.Surface(
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(message, color = MaterialTheme.colorScheme.onErrorContainer, style = MaterialTheme.typography.bodySmall)
|
||||
androidx.compose.foundation.layout.Row {
|
||||
TextButton(onClick = onRetry) { Text(tr("common.retry")) }
|
||||
TextButton(onClick = onDismiss) { Text(tr("common.close")) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun loadingPlaceholder() {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
/** Distinct calendars currently present in the cache, for the filter sheet. */
|
||||
private fun allKnownCalendars(vm: CalendarViewModel): List<CalendarFilterEntry> {
|
||||
val st = vm.state.value
|
||||
return st.events
|
||||
.map { CalendarFilterEntry(calendarKey(it.source, it.calendarId), it.calendarName.ifBlank { it.source }, it.effectiveColor) }
|
||||
.distinctBy { it.key }
|
||||
.sortedBy { it.name.lowercase() }
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
package com.scarriffle.calendarr.ui.calendar
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.scarriffle.calendarr.data.CalendarRepository
|
||||
import com.scarriffle.calendarr.data.SettingsStore
|
||||
import com.scarriffle.calendarr.domain.model.CalEvent
|
||||
import com.scarriffle.calendarr.domain.model.CalViewType
|
||||
import com.scarriffle.calendarr.domain.model.WritableCalendar
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import javax.inject.Inject
|
||||
|
||||
/** Build the "source:id" visibility key, stripping any "<source>-" prefix. */
|
||||
fun calendarKey(source: String, calendarId: String): String {
|
||||
val prefix = "$source-"
|
||||
val id = if (calendarId.startsWith(prefix)) calendarId.removePrefix(prefix) else calendarId
|
||||
return "$source:$id"
|
||||
}
|
||||
|
||||
data class CalendarUiState(
|
||||
val viewType: CalViewType = CalViewType.MONTH,
|
||||
val currentDate: LocalDate = LocalDate.now(),
|
||||
val events: List<CalEvent> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val isBackgroundCaching: Boolean = false,
|
||||
val error: String? = null,
|
||||
val weekStartsOnMonday: Boolean = true,
|
||||
val writableCalendars: List<WritableCalendar> = emptyList(),
|
||||
val hiddenKeys: Set<String> = emptySet(),
|
||||
val banishedKeys: Set<String> = emptySet(),
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class CalendarViewModel @Inject constructor(
|
||||
private val repository: CalendarRepository,
|
||||
private val settingsStore: SettingsStore,
|
||||
) : ViewModel() {
|
||||
|
||||
private val zone: ZoneId = ZoneId.systemDefault()
|
||||
|
||||
private val _state = MutableStateFlow(initialState())
|
||||
val state: StateFlow<CalendarUiState> = _state.asStateFlow()
|
||||
|
||||
// Cache bookkeeping
|
||||
private var cachedStart: Instant? = null
|
||||
private var cachedEnd: Instant? = null
|
||||
private var allCachedEvents: List<CalEvent> = emptyList()
|
||||
|
||||
init {
|
||||
loadVisible()
|
||||
loadWritableCalendars()
|
||||
prefetchBackground()
|
||||
}
|
||||
|
||||
private fun initialState(): CalendarUiState {
|
||||
val s = settingsStore.loadSettings()
|
||||
return CalendarUiState(
|
||||
viewType = CalViewType.fromKey(s.defaultView),
|
||||
weekStartsOnMonday = s.weekStartsOnMonday,
|
||||
hiddenKeys = settingsStore.hiddenCalendarKeys,
|
||||
banishedKeys = settingsStore.banishedCalendarKeys,
|
||||
)
|
||||
}
|
||||
|
||||
// ---- Navigation ----
|
||||
|
||||
fun setViewType(type: CalViewType) {
|
||||
_state.update { it.copy(viewType = type) }
|
||||
loadVisible()
|
||||
}
|
||||
|
||||
fun moveToToday() {
|
||||
_state.update { it.copy(currentDate = LocalDate.now()) }
|
||||
loadVisible()
|
||||
}
|
||||
|
||||
fun navigatePrev() = navigate(-1)
|
||||
fun navigateNext() = navigate(+1)
|
||||
|
||||
private fun navigate(direction: Int) {
|
||||
val st = _state.value
|
||||
val date = st.currentDate
|
||||
val newDate = when (st.viewType) {
|
||||
CalViewType.WEEK -> date.plusWeeks(direction.toLong())
|
||||
CalViewType.DAY -> date.plusDays(direction.toLong())
|
||||
CalViewType.QUARTER -> date.plusMonths((3 * direction).toLong())
|
||||
else -> date.plusMonths(direction.toLong())
|
||||
}
|
||||
_state.update { it.copy(currentDate = newDate) }
|
||||
loadVisible()
|
||||
}
|
||||
|
||||
fun goToDate(date: LocalDate, viewType: CalViewType? = null) {
|
||||
_state.update { it.copy(currentDate = date, viewType = viewType ?: it.viewType) }
|
||||
loadVisible()
|
||||
}
|
||||
|
||||
// ---- Range ----
|
||||
|
||||
fun rangeForCurrentView(): Pair<Instant, Instant> {
|
||||
val st = _state.value
|
||||
val date = st.currentDate
|
||||
return when (st.viewType) {
|
||||
CalViewType.MONTH -> {
|
||||
val start = date.withDayOfMonth(1)
|
||||
instant(start.minusMonths(1)) to instant(start.plusMonths(2))
|
||||
}
|
||||
CalViewType.QUARTER -> {
|
||||
val start = date.withDayOfMonth(1)
|
||||
instant(start) to instant(start.plusMonths(4))
|
||||
}
|
||||
CalViewType.WEEK -> {
|
||||
val weekStart = startOfWeek(date, st.weekStartsOnMonday)
|
||||
instant(weekStart) to instant(weekStart.plusDays(8))
|
||||
}
|
||||
CalViewType.DAY -> instant(date) to instant(date.plusDays(1))
|
||||
CalViewType.AGENDA -> {
|
||||
val today = LocalDate.now()
|
||||
instant(today) to instant(today.plusDays(90))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun instant(date: LocalDate): Instant = date.atStartOfDay(zone).toInstant()
|
||||
|
||||
private fun startOfWeek(date: LocalDate, mondayFirst: Boolean): LocalDate {
|
||||
val dow = date.dayOfWeek.value // Mon=1 .. Sun=7
|
||||
val offset = if (mondayFirst) dow - 1 else dow % 7
|
||||
return date.minusDays(offset.toLong())
|
||||
}
|
||||
|
||||
// ---- Loading ----
|
||||
|
||||
fun loadVisible(force: Boolean = false) {
|
||||
val (start, end) = rangeForCurrentView()
|
||||
if (!force && isCached(start, end)) {
|
||||
refreshFromCache()
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isLoading = true, error = null) }
|
||||
runCatching { repository.fetchEvents(start, end) }
|
||||
.onSuccess { fetched ->
|
||||
mergeIntoCache(fetched, start, end)
|
||||
refreshFromCache()
|
||||
_state.update { it.copy(isLoading = false) }
|
||||
}
|
||||
.onFailure { e ->
|
||||
_state.update { it.copy(isLoading = false, error = e.message) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun prefetchBackground() {
|
||||
val months = settingsStore.cacheMonths
|
||||
val today = LocalDate.now().withDayOfMonth(1)
|
||||
val start = instant(today.minusMonths(months.toLong()))
|
||||
val end = instant(today.plusMonths((months + 1).toLong()))
|
||||
if (isCached(start, end)) return
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isBackgroundCaching = true) }
|
||||
runCatching { repository.fetchEvents(start, end) }
|
||||
.onSuccess { fetched ->
|
||||
mergeIntoCache(fetched, start, end)
|
||||
refreshFromCache()
|
||||
}
|
||||
_state.update { it.copy(isBackgroundCaching = false) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun isCached(start: Instant, end: Instant): Boolean {
|
||||
val cs = cachedStart ?: return false
|
||||
val ce = cachedEnd ?: return false
|
||||
return !cs.isAfter(start) && !ce.isBefore(end)
|
||||
}
|
||||
|
||||
private fun mergeIntoCache(newEvents: List<CalEvent>, rangeStart: Instant, rangeEnd: Instant) {
|
||||
val retained = allCachedEvents.filter {
|
||||
!it.startDate.isBefore(rangeEnd) || !it.endDate.isAfter(rangeStart)
|
||||
}
|
||||
allCachedEvents = retained + newEvents
|
||||
cachedStart = cachedStart?.let { minOf(it, rangeStart) } ?: rangeStart
|
||||
cachedEnd = cachedEnd?.let { maxOf(it, rangeEnd) } ?: rangeEnd
|
||||
}
|
||||
|
||||
private fun refreshFromCache() {
|
||||
val hidden = _state.value.hiddenKeys
|
||||
val banished = _state.value.banishedKeys
|
||||
val visible = allCachedEvents.filter { ev ->
|
||||
val key = calendarKey(ev.source, ev.calendarId)
|
||||
key !in hidden && key !in banished
|
||||
}
|
||||
_state.update { it.copy(events = visible) }
|
||||
}
|
||||
|
||||
private fun invalidateCache() {
|
||||
cachedStart = null
|
||||
cachedEnd = null
|
||||
allCachedEvents = emptyList()
|
||||
}
|
||||
|
||||
fun syncWithServer() {
|
||||
invalidateCache()
|
||||
loadVisible(force = true)
|
||||
prefetchBackground()
|
||||
}
|
||||
|
||||
fun clearError() = _state.update { it.copy(error = null) }
|
||||
|
||||
// ---- Visibility filters ----
|
||||
|
||||
fun setCalendarHidden(key: String, hidden: Boolean) {
|
||||
val next = _state.value.hiddenKeys.toMutableSet().apply {
|
||||
if (hidden) add(key) else remove(key)
|
||||
}
|
||||
settingsStore.hiddenCalendarKeys = next
|
||||
_state.update { it.copy(hiddenKeys = next) }
|
||||
refreshFromCache()
|
||||
}
|
||||
|
||||
fun setHiddenCalendars(keys: Set<String>) {
|
||||
settingsStore.hiddenCalendarKeys = keys
|
||||
_state.update { it.copy(hiddenKeys = keys) }
|
||||
refreshFromCache()
|
||||
}
|
||||
|
||||
fun setCalendarBanished(key: String, banished: Boolean) {
|
||||
val nextBanished = _state.value.banishedKeys.toMutableSet().apply {
|
||||
if (banished) add(key) else remove(key)
|
||||
}
|
||||
val nextHidden = _state.value.hiddenKeys.toMutableSet().apply {
|
||||
if (banished) remove(key)
|
||||
}
|
||||
settingsStore.banishedCalendarKeys = nextBanished
|
||||
settingsStore.hiddenCalendarKeys = nextHidden
|
||||
_state.update { it.copy(banishedKeys = nextBanished, hiddenKeys = nextHidden) }
|
||||
refreshFromCache()
|
||||
}
|
||||
|
||||
// ---- Writable calendars ----
|
||||
|
||||
fun loadWritableCalendars() {
|
||||
viewModelScope.launch {
|
||||
runCatching { repository.getWritableCalendars() }
|
||||
.onSuccess { cals -> _state.update { it.copy(writableCalendars = cals) } }
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Event mutations ----
|
||||
|
||||
private fun afterMutation() {
|
||||
invalidateCache()
|
||||
loadVisible(force = true)
|
||||
prefetchBackground()
|
||||
}
|
||||
|
||||
fun saveEvent(
|
||||
calendar: WritableCalendar,
|
||||
existing: CalEvent?,
|
||||
title: String,
|
||||
start: Instant,
|
||||
end: Instant,
|
||||
isAllDay: Boolean,
|
||||
location: String,
|
||||
description: String,
|
||||
color: String?,
|
||||
onResult: (String?) -> Unit,
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
val result = runCatching {
|
||||
if (existing != null && existing.source == calendar.source) {
|
||||
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)
|
||||
else -> createForSource(calendar, title, start, end, isAllDay, location, description, color)
|
||||
}
|
||||
} else {
|
||||
createForSource(calendar, title, start, end, isAllDay, location, description, color)
|
||||
}
|
||||
}
|
||||
result.onSuccess { afterMutation(); onResult(null) }
|
||||
.onFailure { onResult(it.message ?: "Fehler") }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun createForSource(
|
||||
calendar: WritableCalendar, title: String, start: Instant, end: Instant,
|
||||
isAllDay: Boolean, location: String, description: String, color: String?,
|
||||
) {
|
||||
when (calendar.source) {
|
||||
"local" -> repository.createLocalEvent(calendar.numericId, title, start, end, isAllDay, location, description, color)
|
||||
"caldav" -> repository.createCalDAVEvent(calendar.numericId, title, start, end, isAllDay, location, description, color)
|
||||
"google" -> repository.createGoogleEvent(calendar.numericId, title, start, end, isAllDay, location, description)
|
||||
"homeassistant" -> repository.createHAEvent(calendar.numericId, title, start, end, isAllDay, location, description)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteEvent(event: CalEvent, onResult: (String?) -> Unit) {
|
||||
viewModelScope.launch {
|
||||
val result = runCatching {
|
||||
when (event.source) {
|
||||
"local" -> repository.deleteLocalEvent(event.id)
|
||||
"caldav" -> repository.deleteCalDAVEvent(event.id, event.url, calendarNumericId(event))
|
||||
"homeassistant" -> repository.deleteHAEvent(calendarNumericId(event) ?: 0, event.id)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
result.onSuccess {
|
||||
removeCachedEvent(event.id)
|
||||
onResult(null)
|
||||
}.onFailure { onResult(it.message ?: "Fehler") }
|
||||
}
|
||||
}
|
||||
|
||||
private fun calendarNumericId(event: CalEvent): Int? {
|
||||
val key = calendarKey(event.source, event.calendarId)
|
||||
return key.substringAfter(":").toIntOrNull()
|
||||
}
|
||||
|
||||
private fun removeCachedEvent(id: String) {
|
||||
allCachedEvents = allCachedEvents.filterNot { it.id == id }
|
||||
refreshFromCache()
|
||||
}
|
||||
|
||||
/** Events overlapping a single day, sorted by start. */
|
||||
fun eventsOn(date: LocalDate, events: List<CalEvent>): List<CalEvent> {
|
||||
val dayStart = instant(date)
|
||||
val dayEnd = instant(date.plusDays(1))
|
||||
return events.filter { it.startDate.isBefore(dayEnd) && it.endDate.isAfter(dayStart) }
|
||||
.sortedBy { it.startDate }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package com.scarriffle.calendarr.ui.calendar
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.width
|
||||
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.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.util.colorFromHex
|
||||
import com.scarriffle.calendarr.util.contrastingTextColor
|
||||
import java.time.LocalDate
|
||||
import java.time.temporal.IsoFields
|
||||
|
||||
@Composable
|
||||
fun MonthView(
|
||||
state: CalendarUiState,
|
||||
vm: CalendarViewModel,
|
||||
onDayClick: (LocalDate) -> Unit,
|
||||
onEventClick: (CalEvent) -> Unit,
|
||||
) {
|
||||
val lang = LocalLang.current
|
||||
val mondayFirst = state.weekStartsOnMonday
|
||||
val today = LocalDate.now()
|
||||
val firstOfMonth = state.currentDate.withDayOfMonth(1)
|
||||
val firstVisible = startOfWeekFor(firstOfMonth, mondayFirst)
|
||||
val weeks = (0 until 6).map { w -> (0 until 7).map { d -> firstVisible.plusDays((w * 7 + d).toLong()) } }
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
// Header: CW + weekdays
|
||||
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||
Box(Modifier.width(28.dp))
|
||||
weekdayLabels(mondayFirst, lang).forEach { label ->
|
||||
Text(
|
||||
label,
|
||||
modifier = Modifier.weight(1f),
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
weeks.forEach { week ->
|
||||
Row(Modifier.fillMaxWidth().weight(1f)) {
|
||||
val cw = week.first().get(IsoFields.WEEK_OF_WEEK_BASED_YEAR)
|
||||
Box(Modifier.width(28.dp).fillMaxSize(), contentAlignment = Alignment.TopCenter) {
|
||||
Text(
|
||||
"$cw",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
fontSize = 9.sp,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
week.forEach { day ->
|
||||
DayCell(
|
||||
day = day,
|
||||
inMonth = day.month == firstOfMonth.month,
|
||||
isToday = day == today,
|
||||
events = vm.eventsOn(day, state.events),
|
||||
onClick = { onDayClick(day) },
|
||||
onEventClick = onEventClick,
|
||||
modifier = Modifier.weight(1f).fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DayCell(
|
||||
day: LocalDate,
|
||||
inMonth: Boolean,
|
||||
isToday: Boolean,
|
||||
events: List<CalEvent>,
|
||||
onClick: () -> Unit,
|
||||
onEventClick: (CalEvent) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val todayColor = colorFromHex(LocalAppSettings.current.todayColor)
|
||||
Column(
|
||||
modifier = modifier
|
||||
.padding(1.dp)
|
||||
.clickable(onClick = onClick),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.padding(top = 2.dp)) {
|
||||
if (isToday) {
|
||||
Box(
|
||||
Modifier
|
||||
.height(22.dp).width(22.dp)
|
||||
.clip(RoundedCornerShape(11.dp))
|
||||
.background(todayColor),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
"${day.dayOfMonth}",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = if (isToday) FontWeight.Bold else FontWeight.Normal,
|
||||
color = when {
|
||||
isToday -> todayColor.contrastingTextColor()
|
||||
inMonth -> MaterialTheme.colorScheme.onBackground
|
||||
else -> MaterialTheme.colorScheme.outline
|
||||
},
|
||||
)
|
||||
}
|
||||
events.take(3).forEach { ev ->
|
||||
EventChip(ev, onClick = { onEventClick(ev) })
|
||||
}
|
||||
if (events.size > 3) {
|
||||
Text(
|
||||
"+${events.size - 3}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontSize = 8.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EventChip(event: CalEvent, onClick: () -> Unit) {
|
||||
val color = colorFromHex(event.effectiveColor)
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 1.dp, vertical = 1.dp)
|
||||
.clip(RoundedCornerShape(3.dp))
|
||||
.background(color)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 3.dp, vertical = 1.dp),
|
||||
) {
|
||||
Text(
|
||||
event.title,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
fontSize = 9.sp,
|
||||
color = color.contrastingTextColor(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startOfWeekFor(date: LocalDate, mondayFirst: Boolean): LocalDate {
|
||||
val dow = date.dayOfWeek.value
|
||||
val offset = if (mondayFirst) dow - 1 else dow % 7
|
||||
return date.minusDays(offset.toLong())
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.scarriffle.calendarr.ui.calendar
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
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.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.scarriffle.calendarr.ui.LocalAppSettings
|
||||
import com.scarriffle.calendarr.ui.LocalLang
|
||||
import com.scarriffle.calendarr.util.colorFromHex
|
||||
import com.scarriffle.calendarr.util.contrastingTextColor
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@Composable
|
||||
fun QuarterView(state: CalendarUiState, onDayClick: (LocalDate) -> Unit) {
|
||||
val lang = LocalLang.current
|
||||
val months = (0 until 3).map { state.currentDate.withDayOfMonth(1).plusMonths(it.toLong()) }
|
||||
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(12.dp)) {
|
||||
months.forEach { month ->
|
||||
MiniMonth(month, state, lang, onDayClick)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MiniMonth(month: LocalDate, state: CalendarUiState, lang: String, onDayClick: (LocalDate) -> Unit) {
|
||||
val mondayFirst = state.weekStartsOnMonday
|
||||
val today = LocalDate.now()
|
||||
val titleFmt = DateTimeFormatter.ofPattern("LLLL yyyy", com.scarriffle.calendarr.ui.L10n.locale(lang))
|
||||
val dow = month.dayOfWeek.value
|
||||
val offset = if (mondayFirst) dow - 1 else dow % 7
|
||||
val firstVisible = month.minusDays(offset.toLong())
|
||||
val cells = (0 until 42).map { firstVisible.plusDays(it.toLong()) }
|
||||
|
||||
Column(Modifier.padding(bottom = 16.dp)) {
|
||||
Text(
|
||||
titleFmt.format(month).replaceFirstChar { it.uppercase() },
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(bottom = 4.dp),
|
||||
)
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
weekdayLabels(mondayFirst, lang).forEach {
|
||||
Text(it, Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 9.sp, color = MaterialTheme.colorScheme.outline)
|
||||
}
|
||||
}
|
||||
cells.chunked(7).forEach { week ->
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
week.forEach { day ->
|
||||
val hasEvents = state.events.any {
|
||||
localDate(it.startDate) <= day && localDate(it.endDate) >= day
|
||||
}
|
||||
val firstColor = state.events.firstOrNull {
|
||||
localDate(it.startDate) <= day && localDate(it.endDate) >= day
|
||||
}?.effectiveColor
|
||||
Box(
|
||||
Modifier.weight(1f).aspectRatio(1f).clickable { onDayClick(day) },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
val isToday = day == today
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
if (isToday) {
|
||||
Box(
|
||||
Modifier.clip(CircleShape)
|
||||
.background(colorFromHex(LocalAppSettings.current.todayColor))
|
||||
.padding(horizontal = 5.dp, vertical = 2.dp),
|
||||
) { Text("${day.dayOfMonth}", fontSize = 10.sp, color = colorFromHex(LocalAppSettings.current.todayColor).contrastingTextColor()) }
|
||||
} else {
|
||||
Text(
|
||||
"${day.dayOfMonth}",
|
||||
fontSize = 10.sp,
|
||||
color = if (day.month == month.month) MaterialTheme.colorScheme.onBackground else MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (hasEvents) {
|
||||
Box(Modifier.padding(top = 1.dp).clip(CircleShape).background(colorFromHex(firstColor)).padding(2.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package com.scarriffle.calendarr.ui.calendar
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
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.material3.Divider
|
||||
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.layout.layout
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
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 com.scarriffle.calendarr.domain.model.CalEvent
|
||||
import com.scarriffle.calendarr.ui.LocalAppSettings
|
||||
import com.scarriffle.calendarr.ui.LocalLang
|
||||
import com.scarriffle.calendarr.util.colorFromHex
|
||||
import com.scarriffle.calendarr.util.contrastingTextColor
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
private val GUTTER = 48.dp
|
||||
|
||||
@Composable
|
||||
fun TimeGridView(
|
||||
days: List<LocalDate>,
|
||||
state: CalendarUiState,
|
||||
vm: CalendarViewModel,
|
||||
onEventClick: (CalEvent) -> Unit,
|
||||
) {
|
||||
val hourHeight = LocalAppSettings.current.hourHeight.coerceIn(28, 100).dp
|
||||
val lang = LocalLang.current
|
||||
val today = LocalDate.now()
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
// Day headers (only for multi-day / week view)
|
||||
if (days.size > 1) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Box(Modifier.width(GUTTER))
|
||||
val dayFmt = DateTimeFormatter.ofPattern("EEE d", com.scarriffle.calendarr.ui.L10n.locale(lang))
|
||||
days.forEach { day ->
|
||||
Text(
|
||||
dayFmt.format(day),
|
||||
modifier = Modifier.weight(1f).padding(vertical = 4.dp),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = if (day == today) FontWeight.Bold else FontWeight.Normal,
|
||||
color = if (day == today) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// All-day row
|
||||
val allDay = days.map { d -> d to vm.eventsOn(d, state.events).filter { it.isAllDay } }
|
||||
if (allDay.any { it.second.isNotEmpty() }) {
|
||||
Row(Modifier.fillMaxWidth().padding(bottom = 2.dp)) {
|
||||
Box(Modifier.width(GUTTER), contentAlignment = Alignment.Center) {
|
||||
Text(com.scarriffle.calendarr.ui.tr("cal.allday"), fontSize = 8.sp, color = MaterialTheme.colorScheme.outline)
|
||||
}
|
||||
allDay.forEach { (_, evs) ->
|
||||
Column(Modifier.weight(1f).padding(horizontal = 1.dp)) {
|
||||
evs.forEach { ev -> AllDayChip(ev, onClick = { onEventClick(ev) }) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
|
||||
// Scrollable hour grid
|
||||
Box(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {
|
||||
Box(Modifier.fillMaxWidth().height(hourHeight * 24)) {
|
||||
// Hour lines + labels
|
||||
for (h in 0..24) {
|
||||
val y = hourHeight * h.toFloat()
|
||||
Divider(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = GUTTER)
|
||||
.offset(y = y),
|
||||
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
|
||||
)
|
||||
if (h < 24) {
|
||||
Text(
|
||||
"%02d:00".format(h),
|
||||
modifier = Modifier.width(GUTTER).offset(y = y + 2.dp).padding(end = 4.dp),
|
||||
textAlign = TextAlign.End,
|
||||
fontSize = 9.sp,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Timed events per day column
|
||||
days.forEachIndexed { index, day ->
|
||||
val timed = vm.eventsOn(day, state.events).filter { !it.isAllDay }
|
||||
timed.forEach { ev ->
|
||||
TimedEvent(
|
||||
event = ev,
|
||||
day = day,
|
||||
index = index,
|
||||
dayCount = days.size,
|
||||
hourHeight = hourHeight,
|
||||
onClick = { onEventClick(ev) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TimedEvent(
|
||||
event: CalEvent,
|
||||
day: LocalDate,
|
||||
index: Int,
|
||||
dayCount: Int,
|
||||
hourHeight: androidx.compose.ui.unit.Dp,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val startMin = if (localDate(event.startDate).isBefore(day)) 0 else minutesOfDay(event.startDate)
|
||||
val endMin = if (localDate(event.endDate).isAfter(day)) 24 * 60 else minutesOfDay(event.endDate)
|
||||
val duration = (endMin - startMin).coerceAtLeast(30)
|
||||
val top = hourHeight * (startMin / 60f)
|
||||
val height = (hourHeight * (duration / 60f))
|
||||
val color = colorFromHex(event.effectiveColor)
|
||||
val lang = LocalLang.current
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.offset(y = top)
|
||||
.columnSlot(index, dayCount)
|
||||
.height(height)
|
||||
.padding(horizontal = 1.dp, vertical = 0.5.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(color)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
event.title,
|
||||
fontSize = 10.sp,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = color.contrastingTextColor(),
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
if (height > 36.dp) {
|
||||
Text(
|
||||
eventTimeRange(event, lang),
|
||||
fontSize = 8.sp,
|
||||
color = color.contrastingTextColor().copy(alpha = 0.85f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Position a child in column [index] of [dayCount] columns, after the gutter. */
|
||||
private fun Modifier.columnSlot(index: Int, dayCount: Int): Modifier = layout { measurable, constraints ->
|
||||
val gutterPx = GUTTER.roundToPx()
|
||||
val available = (constraints.maxWidth - gutterPx).coerceAtLeast(0)
|
||||
val colWidth = available / dayCount
|
||||
val placeable = measurable.measure(
|
||||
constraints.copy(minWidth = colWidth, maxWidth = colWidth)
|
||||
)
|
||||
layout(constraints.maxWidth, placeable.height) {
|
||||
placeable.place(gutterPx + colWidth * index, 0)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AllDayChip(event: CalEvent, onClick: () -> Unit) {
|
||||
val color = colorFromHex(event.effectiveColor)
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 1.dp)
|
||||
.clip(RoundedCornerShape(3.dp))
|
||||
.background(color)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 3.dp, vertical = 1.dp),
|
||||
) {
|
||||
Text(event.title, fontSize = 9.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, color = color.contrastingTextColor())
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DayView(state: CalendarUiState, vm: CalendarViewModel, onEventClick: (CalEvent) -> Unit) {
|
||||
TimeGridView(listOf(state.currentDate), state, vm, onEventClick)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WeekView(state: CalendarUiState, vm: CalendarViewModel, onEventClick: (CalEvent) -> Unit) {
|
||||
val mondayFirst = state.weekStartsOnMonday
|
||||
val dow = state.currentDate.dayOfWeek.value
|
||||
val offset = if (mondayFirst) dow - 1 else dow % 7
|
||||
val weekStart = state.currentDate.minusDays(offset.toLong())
|
||||
val days = (0 until 7).map { weekStart.plusDays(it.toLong()) }
|
||||
TimeGridView(days, state, vm, onEventClick)
|
||||
}
|
||||
Reference in New Issue
Block a user