From 87f9a346cc79f391bc0d64b64c6bacdd30e79ea5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 16:01:54 +0000 Subject: [PATCH] refactor(calendars): DST-safe nav, shared header, appointment-view adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correctness: - Replaced millisecond arithmetic with LocalDate.plus/minusDays in day and week views. Day stepping with MILLIS_IN_DAY drifts at DST transitions (a day is 23h or 25h), so after a couple of spring/fall crossings 'next day' landed on the wrong calendar date. DRY: - Introduced CalendarAppointmentView, a small projection that exposes title/image/summary/location/start/end/isAllDay for both 31922 and 31923 events. Three call sites previously did a 4-block `when (event) { is Time -> e.x(); is Date -> e.x() }` per accessor; they're now single linear reads. - Extracted CalendarNavigationHeader for the shared [◀] title [▶] pattern used identically by month, week and day views. - Moved groupByDayKey from CalendarMonthView.kt into the dal package alongside calendarLocalDayKey — it's used by all three grid views, not just the month view. Cleanup: - `Modifier.size(width = 72.dp, height = Dp.Unspecified)` in DayRow was the residue of an earlier failed `width()` helper; replaced with the native `Modifier.width(72.dp)`. - Removed obsolete startOfWeekMs / dayKeyForMs / MILLIS_IN_DAY / MILLIS_PER_DAY / MILLIS_PER_WEEK helpers along with their imports. Line counts: MonthView 341→273, WeekView 299→234, DayView 270→196, EventListCard 236→201. https://claude.ai/code/session_01CbyrA2GdM4EQh8T6nsst6U --- .../loggedIn/calendars/CalendarDayView.kt | 132 ++++------------ .../calendars/CalendarEventListCard.kt | 67 ++------ .../loggedIn/calendars/CalendarMonthView.kt | 146 +++++------------- .../calendars/CalendarNavigationHeader.kt | 81 ++++++++++ .../loggedIn/calendars/CalendarTimeFormat.kt | 17 +- .../loggedIn/calendars/CalendarWeekView.kt | 127 ++++----------- .../calendars/dal/CalendarAppointmentView.kt | 71 +++++++++ .../calendars/dal/CalendarSortKeys.kt | 13 ++ 8 files changed, 287 insertions(+), 367 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarNavigationHeader.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/dal/CalendarAppointmentView.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarDayView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarDayView.kt index 464e3274cb..7e7066cdca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarDayView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarDayView.kt @@ -30,12 +30,11 @@ 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.RoundedCornerShape import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -48,24 +47,19 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.commons.icons.symbols.Icon -import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent -import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent -import java.time.Instant +import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.appointmentView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.groupByDayKey import java.time.LocalDate import java.time.ZoneId -import java.util.Calendar @Composable fun CalendarDayView( @@ -83,26 +77,23 @@ fun CalendarDayView( else -> emptyList() } - val today = remember { Calendar.getInstance() } - var dayMs by rememberSaveable { - mutableStateOf(startOfDayMs(today)) - } + val today = remember { LocalDate.now() } + // Persisting an epoch-day Long is auto-saveable; arithmetic in [LocalDate] is DST-safe + // (millisecond stepping was off by an hour after spring/fall transitions). + var visibleEpochDay by rememberSaveable { mutableStateOf(today.toEpochDay()) } + val visibleDate = LocalDate.ofEpochDay(visibleEpochDay) - // groupByDayKey only depends on `notes`; keying on dayMs would needlessly recreate - // the derived state on every day navigation. - val byDay by remember(notes) { - derivedStateOf { groupByDayKey(notes) } - } - - val dayKey = localDateForMs(dayMs).toEpochDay() - val dayEvents = byDay[dayKey].orEmpty() + val byDay by remember(notes) { derivedStateOf { groupByDayKey(notes) } } + val dayEvents = byDay[visibleDate.toEpochDay()].orEmpty() Column(modifier = Modifier.fillMaxSize()) { - DayHeader( - dayMs = dayMs, - onPrev = { dayMs -= MILLIS_IN_DAY }, - onNext = { dayMs += MILLIS_IN_DAY }, - onToday = { dayMs = startOfDayMs(Calendar.getInstance()) }, + CalendarNavigationHeader( + title = formatLongDate(visibleDate.atStartOfDay(ZoneId.systemDefault()).toEpochSecond()), + prevContentDescription = "Previous day", + nextContentDescription = "Next day", + onPrev = { visibleEpochDay = visibleDate.minusDays(1).toEpochDay() }, + onNext = { visibleEpochDay = visibleDate.plusDays(1).toEpochDay() }, + onToday = { visibleEpochDay = LocalDate.now().toEpochDay() }, ) if (dayEvents.isEmpty()) { @@ -123,44 +114,6 @@ fun CalendarDayView( } } -@Composable -private fun DayHeader( - dayMs: Long, - onPrev: () -> Unit, - onNext: () -> Unit, - onToday: () -> Unit, -) { - val cal = Calendar.getInstance().apply { timeInMillis = dayMs } - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton(onClick = onPrev) { - Icon( - symbol = MaterialSymbols.AutoMirrored.ArrowBack, - contentDescription = "Previous day", - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onSurface, - ) - } - Text( - text = formatLongDate(cal.timeInMillis / 1000), - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.weight(1f).clickable(onClick = onToday), - textAlign = TextAlign.Center, - fontWeight = FontWeight.Bold, - ) - IconButton(onClick = onNext) { - Icon( - symbol = MaterialSymbols.ChevronRight, - contentDescription = "Next day", - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onSurface, - ) - } - } -} - @Composable private fun DayTimeline( dayEvents: List, @@ -168,13 +121,8 @@ private fun DayTimeline( ) { val sorted = remember(dayEvents) { - dayEvents.sortedBy { - when (val e = it.event) { - is CalendarTimeSlotEvent -> e.start() ?: Long.MAX_VALUE - is CalendarDateSlotEvent -> 0L - else -> Long.MAX_VALUE - } - } + // All-day events bubble to the top (Long.MIN_VALUE), then time-slot events in order. + dayEvents.sortedBy { it.appointmentView()?.startSeconds ?: Long.MAX_VALUE } } LazyColumn(modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp)) { @@ -190,24 +138,14 @@ private fun DayRow( note: Note, onClick: () -> Unit, ) { + val view = note.appointmentView() ?: return + val timeLabel = - when (val e = note.event) { - is CalendarTimeSlotEvent -> e.start()?.let { formatTimeOfDay(it) } ?: "—" - is CalendarDateSlotEvent -> "All day" + when { + view.isAllDay -> "All day" + view.startSeconds != null -> formatTimeOfDay(view.startSeconds) else -> "—" } - val title = - when (val e = note.event) { - is CalendarTimeSlotEvent -> e.title() - is CalendarDateSlotEvent -> e.title() - else -> null - } - val location = - when (val e = note.event) { - is CalendarTimeSlotEvent -> e.location() - is CalendarDateSlotEvent -> e.location() - else -> null - } Row( modifier = @@ -222,19 +160,20 @@ private fun DayRow( style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(width = 72.dp, height = androidx.compose.ui.unit.Dp.Unspecified), + modifier = Modifier.width(72.dp), ) Box( modifier = Modifier - .size(width = 3.dp, height = 40.dp) + .width(3.dp) + .height(40.dp) .background(MaterialTheme.colorScheme.primary, RoundedCornerShape(2.dp)), ) Column( modifier = Modifier.padding(start = 12.dp), verticalArrangement = Arrangement.spacedBy(2.dp), ) { - title?.let { + view.title?.let { Text( text = it, style = MaterialTheme.typography.bodyLarge, @@ -243,7 +182,7 @@ private fun DayRow( overflow = TextOverflow.Ellipsis, ) } - location?.let { + view.location?.let { Text( text = it, style = MaterialTheme.typography.bodySmall, @@ -255,16 +194,3 @@ private fun DayRow( } } } - -private const val MILLIS_IN_DAY: Long = 24L * 60L * 60L * 1000L - -private fun startOfDayMs(cal: Calendar): Long { - val c = cal.clone() as Calendar - c.set(Calendar.HOUR_OF_DAY, 0) - c.set(Calendar.MINUTE, 0) - c.set(Calendar.SECOND, 0) - c.set(Calendar.MILLISECOND, 0) - return c.timeInMillis -} - -private fun localDateForMs(ms: Long): LocalDate = Instant.ofEpochMilli(ms).atZone(ZoneId.systemDefault()).toLocalDate() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarEventListCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarEventListCard.kt index 062e55270e..5707f143e5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarEventListCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarEventListCard.kt @@ -33,7 +33,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -51,9 +50,7 @@ import com.vitorpamplona.amethyst.ui.components.MyAsyncImage import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.calendarStartSeconds -import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent -import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent +import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.appointmentView import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter @@ -71,34 +68,7 @@ fun CalendarEventListCard( nav: INav, modifier: Modifier = Modifier, ) { - val event = note.event - if (event !is CalendarTimeSlotEvent && event !is CalendarDateSlotEvent) return - - val title = - when (event) { - is CalendarTimeSlotEvent -> event.title() - is CalendarDateSlotEvent -> event.title() - else -> null - } - val location = - when (event) { - is CalendarTimeSlotEvent -> event.location() - is CalendarDateSlotEvent -> event.location() - else -> null - } - val image = - when (event) { - is CalendarTimeSlotEvent -> event.image() - is CalendarDateSlotEvent -> event.image() - else -> null - } - val summary = - when (event) { - is CalendarTimeSlotEvent -> event.summary() - is CalendarDateSlotEvent -> event.summary() - else -> null - } - + val view = note.appointmentView() ?: return val range = remember(note.idHex) { formatCalendarRange(note) } Card( @@ -115,7 +85,7 @@ fun CalendarEventListCard( modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.Top, ) { - CalendarDateBadge(note) + CalendarDateBadge(view.startSeconds) Spacer(modifier = Modifier.size(12.dp)) @@ -123,7 +93,7 @@ fun CalendarEventListCard( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp), ) { - title?.let { + view.title?.let { Text( text = it, style = MaterialTheme.typography.titleMedium, @@ -141,7 +111,7 @@ fun CalendarEventListCard( overflow = TextOverflow.Ellipsis, ) } - location?.let { + view.location?.let { Row(verticalAlignment = Alignment.CenterVertically) { Icon( symbol = MaterialSymbols.LocationOn, @@ -159,11 +129,11 @@ fun CalendarEventListCard( ) } } - if (!image.isNullOrBlank()) { + if (!view.image.isNullOrBlank()) { Spacer(modifier = Modifier.size(4.dp)) MyAsyncImage( - imageUrl = image, - contentDescription = title, + imageUrl = view.image, + contentDescription = view.title, contentScale = ContentScale.Crop, mainImageModifier = Modifier.fillMaxWidth().height(120.dp), loadedImageModifier = Modifier, @@ -172,9 +142,9 @@ fun CalendarEventListCard( onError = { Box(modifier = Modifier.fillMaxWidth().height(120.dp)) }, ) } - if (!summary.isNullOrBlank() && image.isNullOrBlank()) { + if (!view.summary.isNullOrBlank() && view.image.isNullOrBlank()) { Text( - text = summary, + text = view.summary, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2, @@ -187,13 +157,10 @@ fun CalendarEventListCard( } @Composable -private fun CalendarDateBadge(note: Note) { - val start = remember(note.idHex) { note.calendarStartSeconds() } - if (start == null) { +private fun CalendarDateBadge(startSeconds: Long?) { + if (startSeconds == null) { Box( - modifier = - Modifier - .size(width = 52.dp, height = 60.dp), + modifier = Modifier.size(width = 52.dp, height = 60.dp), contentAlignment = Alignment.Center, ) { Icon( @@ -207,16 +174,14 @@ private fun CalendarDateBadge(note: Note) { } val localDate = - remember(start) { - Instant.ofEpochSecond(start).atZone(ZoneId.systemDefault()).toLocalDate() + remember(startSeconds) { + Instant.ofEpochSecond(startSeconds).atZone(ZoneId.systemDefault()).toLocalDate() } val day = localDate.dayOfMonth.toString() val month = remember(localDate) { MonthShortFormatter.format(localDate).uppercase() } Column( - modifier = - Modifier - .size(width = 52.dp, height = 60.dp), + modifier = Modifier.size(width = 52.dp, height = 60.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarMonthView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarMonthView.kt index 7f9bda2e6b..1c981232ee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarMonthView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarMonthView.kt @@ -37,7 +37,6 @@ 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.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -54,16 +53,14 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.commons.icons.symbols.Icon -import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.calendarLocalDayKey +import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.groupByDayKey import java.time.LocalDate -import java.util.Calendar +import java.time.YearMonth @Composable fun CalendarMonthView( @@ -81,43 +78,36 @@ fun CalendarMonthView( else -> emptyList() } - val today = remember { Calendar.getInstance() } - var year by rememberSaveable { mutableStateOf(today.get(Calendar.YEAR)) } - var month by rememberSaveable { mutableStateOf(today.get(Calendar.MONTH)) } + val today = remember { LocalDate.now() } + // YearMonth is not Parcelable/auto-saveable; persist the two ints and rebuild on each read. + var visibleYear by rememberSaveable { mutableStateOf(today.year) } + var visibleMonthValue by rememberSaveable { mutableStateOf(today.monthValue) } + val visibleMonth = YearMonth.of(visibleYear, visibleMonthValue) - // groupByDayKey only depends on `notes`; keying on year/month would needlessly recreate - // the derived state on every month navigation. - val eventsByDay by remember(notes) { - derivedStateOf { groupByDayKey(notes) } + fun setVisibleMonth(ym: YearMonth) { + visibleYear = ym.year + visibleMonthValue = ym.monthValue } + val eventsByDay by remember(notes) { derivedStateOf { groupByDayKey(notes) } } + var selectedDayKey by rememberSaveable { mutableStateOf(null) } Column(modifier = Modifier.fillMaxSize()) { - MonthHeader( - year = year, - month = month, + CalendarNavigationHeader( + title = formatMonthYear(visibleMonth.year, visibleMonth.monthValue - 1), + prevContentDescription = "Previous month", + nextContentDescription = "Next month", onPrev = { - if (month == 0) { - month = 11 - year -= 1 - } else { - month -= 1 - } + setVisibleMonth(visibleMonth.minusMonths(1)) selectedDayKey = null }, onNext = { - if (month == 11) { - month = 0 - year += 1 - } else { - month += 1 - } + setVisibleMonth(visibleMonth.plusMonths(1)) selectedDayKey = null }, onToday = { - year = today.get(Calendar.YEAR) - month = today.get(Calendar.MONTH) + setVisibleMonth(YearMonth.from(LocalDate.now())) selectedDayKey = null }, ) @@ -125,8 +115,8 @@ fun CalendarMonthView( WeekdayHeader() MonthGrid( - year = year, - month = month, + visibleMonth = visibleMonth, + today = today, eventsByDay = eventsByDay, selectedDayKey = selectedDayKey, onDayClick = { dayKey -> @@ -147,44 +137,6 @@ fun CalendarMonthView( } } -@Composable -private fun MonthHeader( - year: Int, - month: Int, - onPrev: () -> Unit, - onNext: () -> Unit, - onToday: () -> Unit, -) { - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton(onClick = onPrev) { - Icon( - symbol = MaterialSymbols.AutoMirrored.ArrowBack, - contentDescription = "Previous month", - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onSurface, - ) - } - Text( - text = formatMonthYear(year, month), - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.weight(1f).clickable(onClick = onToday), - textAlign = TextAlign.Center, - fontWeight = FontWeight.Bold, - ) - IconButton(onClick = onNext) { - Icon( - symbol = MaterialSymbols.ChevronRight, - contentDescription = "Next month", - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onSurface, - ) - } - } -} - @Composable private fun WeekdayHeader() { Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp)) { @@ -203,40 +155,34 @@ private fun WeekdayHeader() { @Composable private fun MonthGrid( - year: Int, - month: Int, + visibleMonth: YearMonth, + today: LocalDate, eventsByDay: Map>, selectedDayKey: Long?, onDayClick: (Long) -> Unit, ) { - val cal = Calendar.getInstance() - cal.clear() - cal.set(year, month, 1) - val firstWeekday = cal.get(Calendar.DAY_OF_WEEK) - Calendar.SUNDAY // 0..6 - val daysInMonth = cal.getActualMaximum(Calendar.DAY_OF_MONTH) - val totalCells = ((firstWeekday + daysInMonth + 6) / 7) * 7 - val rows = totalCells / 7 - - val todayCal = remember { Calendar.getInstance() } - val isCurrentMonth = year == todayCal.get(Calendar.YEAR) && month == todayCal.get(Calendar.MONTH) - val todayDay = todayCal.get(Calendar.DAY_OF_MONTH) + val firstOfMonth = visibleMonth.atDay(1) + // SUNDAY = 7 in DayOfWeek; we want Sunday = 0 to match `formatShortWeekday`. + val firstWeekdayIndex = firstOfMonth.dayOfWeek.value % 7 + val daysInMonth = visibleMonth.lengthOfMonth() + val rows = ((firstWeekdayIndex + daysInMonth + 6) / 7) + val isCurrentMonth = visibleMonth == YearMonth.from(today) Column(modifier = Modifier.fillMaxWidth()) { for (r in 0 until rows) { Row(modifier = Modifier.fillMaxWidth()) { for (c in 0..6) { val cellIndex = r * 7 + c - val dayNumber = cellIndex - firstWeekday + 1 + val dayNumber = cellIndex - firstWeekdayIndex + 1 if (dayNumber in 1..daysInMonth) { - // Calendar.MONTH is 0-based; LocalDate.of's month is 1-based. - val dayKey = LocalDate.of(year, month + 1, dayNumber).toEpochDay() - val dayEvents = eventsByDay[dayKey].orEmpty() + val date = visibleMonth.atDay(dayNumber) + val dayKey = date.toEpochDay() DayCell( modifier = Modifier.weight(1f), dayNumber = dayNumber, - isToday = isCurrentMonth && dayNumber == todayDay, + isToday = isCurrentMonth && date == today, isSelected = selectedDayKey == dayKey, - eventCount = dayEvents.size, + eventCount = eventsByDay[dayKey]?.size ?: 0, onClick = { onDayClick(dayKey) }, ) } else { @@ -258,9 +204,10 @@ private fun DayCell( onClick: () -> Unit, ) { val bg = - when { - isSelected -> MaterialTheme.colorScheme.primaryContainer - else -> MaterialTheme.colorScheme.surface + if (isSelected) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surface } Box( @@ -302,12 +249,11 @@ private fun EventDotRow(eventCount: Int) { Spacer(modifier = Modifier.height(6.dp)) return } - val displayedDots = eventCount.coerceAtMost(3) Row( horizontalArrangement = Arrangement.spacedBy(2.dp), modifier = Modifier.padding(bottom = 1.dp), ) { - repeat(displayedDots) { + repeat(eventCount.coerceAtMost(3)) { Box( modifier = Modifier @@ -325,17 +271,3 @@ private fun EventDotRow(eventCount: Int) { } } } - -/** - * Buckets events by local calendar day (returned as `LocalDate.toEpochDay`). Time-slot events - * land on the viewer's local date; date-slot events use the ISO date verbatim so "Jan 15" stays - * on Jan 15 in every zone. - */ -fun groupByDayKey(notes: List): Map> { - val map = mutableMapOf>() - notes.forEach { - val dayKey = it.calendarLocalDayKey() ?: return@forEach - map.getOrPut(dayKey) { mutableListOf() }.add(it) - } - return map -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarNavigationHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarNavigationHeader.kt new file mode 100644 index 0000000000..75920ba6ac --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarNavigationHeader.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars + +import androidx.compose.foundation.clickable +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.material3.IconButton +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.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols + +/** + * Shared `[◀] title [▶]` header used by month / week / day view bodies. Tapping the title + * jumps back to today. + */ +@Composable +fun CalendarNavigationHeader( + title: String, + prevContentDescription: String, + nextContentDescription: String, + onPrev: () -> Unit, + onNext: () -> Unit, + onToday: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onPrev) { + Icon( + symbol = MaterialSymbols.AutoMirrored.ArrowBack, + contentDescription = prevContentDescription, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.weight(1f).clickable(onClick = onToday), + textAlign = TextAlign.Center, + fontWeight = FontWeight.Bold, + ) + IconButton(onClick = onNext) { + Icon( + symbol = MaterialSymbols.ChevronRight, + contentDescription = nextContentDescription, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarTimeFormat.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarTimeFormat.kt index 056fa2339d..5e270f1bcd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarTimeFormat.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarTimeFormat.kt @@ -21,10 +21,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.calendarEndSeconds -import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.calendarStartSeconds -import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent -import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent +import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.appointmentView import java.text.SimpleDateFormat import java.util.Calendar import java.util.Date @@ -37,12 +34,12 @@ private val TimeFormat = SimpleDateFormat("h:mm a", Locale.getDefault()) private val WeekdayShortFormat = SimpleDateFormat("EEE", Locale.getDefault()) fun formatCalendarRange(note: Note): String? { - val start = note.calendarStartSeconds() ?: return null - val end = note.calendarEndSeconds() - return when (note.event) { - is CalendarTimeSlotEvent -> formatTimeRange(start, end) - is CalendarDateSlotEvent -> formatDateRange(start, end) - else -> null + val view = note.appointmentView() ?: return null + val start = view.startSeconds ?: return null + return if (view.isAllDay) { + formatDateRange(start, view.endSeconds) + } else { + formatTimeRange(start, view.endSeconds) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarWeekView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarWeekView.kt index c50d256954..939cfd59b7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarWeekView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarWeekView.kt @@ -31,11 +31,9 @@ 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.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -48,21 +46,17 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.commons.icons.symbols.Icon -import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import java.time.Instant +import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.groupByDayKey import java.time.LocalDate import java.time.ZoneId -import java.util.Calendar @Composable fun CalendarWeekView( @@ -80,38 +74,39 @@ fun CalendarWeekView( else -> emptyList() } - val today = remember { Calendar.getInstance() } - var weekStartMs by rememberSaveable { - mutableStateOf(startOfWeekMs(today)) - } - - // groupByDayKey only depends on `notes`; keying on weekStartMs would needlessly recreate - // the derived state on every week navigation. - val eventsByDay by remember(notes) { - derivedStateOf { groupByDayKey(notes) } + val today = remember { LocalDate.now() } + // Persist the week-start as an epoch-day Long (auto-saveable), reconstruct LocalDate on use. + var weekStartEpochDay by rememberSaveable { + mutableStateOf(startOfWeek(today).toEpochDay()) } + val weekStart = LocalDate.ofEpochDay(weekStartEpochDay) var selectedDayIndex by rememberSaveable { mutableStateOf(0) } + val eventsByDay by remember(notes) { derivedStateOf { groupByDayKey(notes) } } + Column(modifier = Modifier.fillMaxSize()) { - WeekHeader( - weekStartMs = weekStartMs, + CalendarNavigationHeader( + title = formatMonthYear(weekStart.year, weekStart.monthValue - 1), + prevContentDescription = "Previous week", + nextContentDescription = "Next week", onPrev = { - weekStartMs -= MILLIS_PER_WEEK + weekStartEpochDay = weekStart.minusWeeks(1).toEpochDay() selectedDayIndex = 0 }, onNext = { - weekStartMs += MILLIS_PER_WEEK + weekStartEpochDay = weekStart.plusWeeks(1).toEpochDay() selectedDayIndex = 0 }, onToday = { - weekStartMs = startOfWeekMs(Calendar.getInstance()) + weekStartEpochDay = startOfWeek(LocalDate.now()).toEpochDay() selectedDayIndex = 0 }, ) WeekStrip( - weekStartMs = weekStartMs, + weekStart = weekStart, + today = today, selectedIndex = selectedDayIndex, eventsByDay = eventsByDay, onSelect = { selectedDayIndex = it }, @@ -119,7 +114,7 @@ fun CalendarWeekView( Spacer(modifier = Modifier.height(8.dp)) - val selectedDate = localDateForOffset(weekStartMs, selectedDayIndex) + val selectedDate = weekStart.plusDays(selectedDayIndex.toLong()) val dayNotes = eventsByDay[selectedDate.toEpochDay()].orEmpty() DaySummaryHeader(selectedDate) @@ -145,67 +140,21 @@ fun CalendarWeekView( } } -@Composable -private fun WeekHeader( - weekStartMs: Long, - onPrev: () -> Unit, - onNext: () -> Unit, - onToday: () -> Unit, -) { - val cal = Calendar.getInstance().apply { timeInMillis = weekStartMs } - val title = formatMonthYear(cal.get(Calendar.YEAR), cal.get(Calendar.MONTH)) - - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton(onClick = onPrev) { - Icon( - symbol = MaterialSymbols.AutoMirrored.ArrowBack, - contentDescription = "Previous week", - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onSurface, - ) - } - Text( - text = title, - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.weight(1f).clickable(onClick = onToday), - textAlign = TextAlign.Center, - fontWeight = FontWeight.Bold, - ) - IconButton(onClick = onNext) { - Icon( - symbol = MaterialSymbols.ChevronRight, - contentDescription = "Next week", - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onSurface, - ) - } - } -} - @Composable private fun WeekStrip( - weekStartMs: Long, + weekStart: LocalDate, + today: LocalDate, selectedIndex: Int, eventsByDay: Map>, onSelect: (Int) -> Unit, ) { - val cal = Calendar.getInstance() - val todayCal = remember { Calendar.getInstance() } - Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp), ) { for (i in 0..6) { - cal.timeInMillis = weekStartMs - cal.add(Calendar.DAY_OF_YEAR, i) - val date = localDateForOffset(weekStartMs, i) + val date = weekStart.plusDays(i.toLong()) val count = eventsByDay[date.toEpochDay()]?.size ?: 0 - val isToday = - cal.get(Calendar.YEAR) == todayCal.get(Calendar.YEAR) && - cal.get(Calendar.DAY_OF_YEAR) == todayCal.get(Calendar.DAY_OF_YEAR) + val isToday = date == today val isSelected = i == selectedIndex val bg = @@ -242,7 +191,7 @@ private fun WeekStrip( fontWeight = FontWeight.SemiBold, ) Text( - text = cal.get(Calendar.DAY_OF_MONTH).toString(), + text = date.dayOfMonth.toString(), style = MaterialTheme.typography.titleMedium, color = fg, fontWeight = FontWeight.Bold, @@ -274,26 +223,12 @@ private fun DaySummaryHeader(date: LocalDate) { ) } -private const val MILLIS_PER_DAY: Long = 24L * 60L * 60L * 1000L -private const val MILLIS_PER_WEEK: Long = 7L * MILLIS_PER_DAY - -private fun startOfWeekMs(cal: Calendar): Long { - val c = cal.clone() as Calendar - c.firstDayOfWeek = Calendar.SUNDAY - c.set(Calendar.HOUR_OF_DAY, 0) - c.set(Calendar.MINUTE, 0) - c.set(Calendar.SECOND, 0) - c.set(Calendar.MILLISECOND, 0) - val dow = c.get(Calendar.DAY_OF_WEEK) - Calendar.SUNDAY - c.add(Calendar.DAY_OF_YEAR, -dow) - return c.timeInMillis +/** + * Returns the Sunday on or before [date]. DST-safe because [LocalDate] arithmetic ignores zones. + * `DayOfWeek.SUNDAY.value` is 7 in java.time, so `% 7` collapses Sunday → 0 with the rest of the + * week following in order. + */ +private fun startOfWeek(date: LocalDate): LocalDate { + val daysFromSunday = date.dayOfWeek.value % 7 + return date.minusDays(daysFromSunday.toLong()) } - -private fun localDateForOffset( - weekStartMs: Long, - offset: Int, -): LocalDate = - Instant - .ofEpochMilli(weekStartMs + offset * MILLIS_PER_DAY) - .atZone(ZoneId.systemDefault()) - .toLocalDate() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/dal/CalendarAppointmentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/dal/CalendarAppointmentView.kt new file mode 100644 index 0000000000..2282cbba9f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/dal/CalendarAppointmentView.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent +import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent + +/** + * Shared projection of NIP-52 calendar appointments. The 31922 (date-slot) and 31923 (time-slot) + * event classes have identical UI surfaces but no common interface, so UI code repeated a + * `when (event) { is Time -> e.title(); is Date -> e.title() }` block per accessor. Materialising + * the projection once collapses those branches into a single linear read. + * + * [isAllDay] discriminates the two kinds; [startSeconds] is local-midnight for 31922 (matching + * the rest of the calendar code's day-anchoring). + */ +@Immutable +data class CalendarAppointmentView( + val title: String?, + val image: String?, + val summary: String?, + val location: String?, + val startSeconds: Long?, + val endSeconds: Long?, + val isAllDay: Boolean, +) + +fun Note.appointmentView(): CalendarAppointmentView? = + when (val e = event) { + is CalendarTimeSlotEvent -> + CalendarAppointmentView( + title = e.title(), + image = e.image(), + summary = e.summary(), + location = e.location(), + startSeconds = e.start(), + endSeconds = e.end() ?: e.start(), + isAllDay = false, + ) + is CalendarDateSlotEvent -> + CalendarAppointmentView( + title = e.title(), + image = e.image(), + summary = e.summary(), + location = e.location(), + startSeconds = parseIsoDateToUnixSeconds(e.start()), + endSeconds = parseIsoDateToUnixSeconds(e.end()) ?: parseIsoDateToUnixSeconds(e.start()), + isAllDay = true, + ) + else -> null + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/dal/CalendarSortKeys.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/dal/CalendarSortKeys.kt index 4187351460..d5312e6afb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/dal/CalendarSortKeys.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/dal/CalendarSortKeys.kt @@ -89,6 +89,19 @@ fun Note.calendarLocalDayKey(): Long? = else -> null } +/** + * Buckets appointments by local calendar day (returned as `LocalDate.toEpochDay`). Notes that + * are not calendar appointments or whose start can't be parsed are dropped. + */ +fun groupByDayKey(notes: List): Map> { + val map = mutableMapOf>() + notes.forEach { + val dayKey = it.calendarLocalDayKey() ?: return@forEach + map.getOrPut(dayKey) { mutableListOf() }.add(it) + } + return map +} + /** * Sort by: upcoming events ascending (closest first), then past events descending (most-recent * first). [nowSeconds] is captured once per sort so the comparator stays transitive across the