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:
Guido Schmit
2026-05-31 12:22:56 +02:00
co-authored by Claude Opus 4.8
parent 676b7ee2c6
commit aff6cef493
26 changed files with 3129 additions and 35 deletions
@@ -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)
}