refactor(calendars): DST-safe nav, shared header, appointment-view adapter

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
This commit is contained in:
Claude
2026-05-19 21:58:32 +00:00
parent 802f89723e
commit 87f9a346cc
8 changed files with 287 additions and 367 deletions
@@ -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<Note>,
@@ -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()
@@ -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,
) {
@@ -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<Long?>(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<Long, List<Note>>,
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<Note>): Map<Long, List<Note>> {
val map = mutableMapOf<Long, MutableList<Note>>()
notes.forEach {
val dayKey = it.calendarLocalDayKey() ?: return@forEach
map.getOrPut(dayKey) { mutableListOf() }.add(it)
}
return map
}
@@ -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,
)
}
}
}
@@ -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)
}
}
@@ -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<Long, List<Note>>,
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()
@@ -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
}
@@ -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<Note>): Map<Long, List<Note>> {
val map = mutableMapOf<Long, MutableList<Note>>()
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