mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
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:
+29
-103
@@ -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()
|
||||
|
||||
+16
-51
@@ -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,
|
||||
) {
|
||||
|
||||
+39
-107
@@ -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
|
||||
}
|
||||
|
||||
+81
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-10
@@ -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
-96
@@ -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()
|
||||
|
||||
+71
@@ -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
|
||||
}
|
||||
+13
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user