From ab5d884b3453b1d92b91eb4bda64ecc33b392339 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 19:51:01 +0000 Subject: [PATCH] feat(calendars): "Add to phone calendar" intent + multi-day bars in month grid - New IconButton in the detail screen opens the system event composer (Google Calendar / Samsung / iCloud) pre-filled with title, range, location, and description via Intent.ACTION_INSERT on CalendarContract. Falls back to the .ics share path if no calendar app is installed. - Replaced the per-cell event-dot row with horizontal bars that span the cells a multi-day event covers. Bars are laid out by a greedy lowest-lane assignment so overlapping events stack rather than collide; cells removed their horizontal padding so adjacent bars merge into one uninterrupted line. - Bars round their ends only at the event's actual start/end (or at week boundaries), so a 3-day conference reads as one continuous pill across the row. - Added MonthGridBarsTest (7 tests) covering single/multi-day, lane collision, longer-event tiebreak, and the empty/ghost edge cases. --- .../loggedIn/calendars/AddToPhoneCalendar.kt | 103 ++++++++++++++ .../loggedIn/calendars/CalendarMonthView.kt | 113 ++++++++++----- .../loggedIn/calendars/dal/MonthGridBars.kt | 98 +++++++++++++ .../detail/CalendarEventDetailScreen.kt | 19 +++ amethyst/src/main/res/values/strings.xml | 1 + .../amethyst/calendar/MonthGridBarsTest.kt | 129 ++++++++++++++++++ .../commons/icons/symbols/MaterialSymbols.kt | 1 + 7 files changed, 431 insertions(+), 33 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/AddToPhoneCalendar.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/dal/MonthGridBars.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/MonthGridBarsTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/AddToPhoneCalendar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/AddToPhoneCalendar.kt new file mode 100644 index 000000000..e0b1ba1fe --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/AddToPhoneCalendar.kt @@ -0,0 +1,103 @@ +/* + * 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 android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.provider.CalendarContract +import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.parseIsoDateToUnixSeconds +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent +import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent + +/** + * Opens the system "New Event" composer (Google Calendar / Samsung / iCloud / etc.) pre-populated + * with this appointment's title, time, location, and description. The user sees their normal + * calendar UI with one tap to "Save" — strictly nicer than the .ics-via-share-sheet path because + * it doesn't go through a file and surfaces the user's preferred calendar app directly. + * + * Returns true if a calendar app handled the intent, false if no handler was found — the caller + * can decide whether to fall back to the .ics share path. + */ +fun addToPhoneCalendar( + context: Context, + event: Event, +): Boolean { + val (title, location, summary) = + when (event) { + is CalendarTimeSlotEvent -> Triple(event.title(), event.location(), event.summary()) + is CalendarDateSlotEvent -> Triple(event.title(), event.location(), event.summary()) + else -> return false + } + val (beginMs, endMs, allDay) = computeRangeMs(event) ?: return false + + val description = + buildString { + summary?.let { append(it) } + if (event.content.isNotBlank()) { + if (isNotEmpty()) append("\n\n") + append(event.content) + } + } + + val intent = + Intent(Intent.ACTION_INSERT).apply { + data = CalendarContract.Events.CONTENT_URI + putExtra(CalendarContract.Events.TITLE, title.orEmpty()) + location?.let { putExtra(CalendarContract.Events.EVENT_LOCATION, it) } + if (description.isNotEmpty()) { + putExtra(CalendarContract.Events.DESCRIPTION, description) + } + putExtra(CalendarContract.EXTRA_EVENT_BEGIN_TIME, beginMs) + putExtra(CalendarContract.EXTRA_EVENT_END_TIME, endMs) + putExtra(CalendarContract.EXTRA_EVENT_ALL_DAY, allDay) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + + return try { + context.startActivity(intent) + true + } catch (_: ActivityNotFoundException) { + false + } +} + +/** + * Computes the (beginMillis, endMillis, isAllDay) triple from a calendar event. NIP-52 date-slot + * uses ISO dates that we anchor at local midnight; time-slot uses unix seconds. End defaults to + * begin + 1 hour for time-slot events without an end and to begin + 1 day for all-day events + * without an end (calendar providers expect end > start for any visible event). + */ +private fun computeRangeMs(event: Event): Triple? = + when (event) { + is CalendarTimeSlotEvent -> { + val start = event.start() ?: return null + val end = event.end() ?: (start + 3600L) + Triple(start * 1000L, end * 1000L, false) + } + is CalendarDateSlotEvent -> { + val startSec = parseIsoDateToUnixSeconds(event.start()) ?: return null + val endSec = parseIsoDateToUnixSeconds(event.end()) ?: (startSec + 86400L) + Triple(startSec * 1000L, endSec * 1000L, true) + } + else -> null + } 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 5eee4ff57..fa26e2b4b 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 @@ -32,10 +32,8 @@ 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.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -48,7 +46,6 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -56,9 +53,11 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R 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.MONTH_GRID_MAX_LANES +import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.MonthGridBarSegment +import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.computeMonthGridBars import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.groupByDayKeyExpanded import com.vitorpamplona.amethyst.ui.stringRes import java.time.LocalDate @@ -94,6 +93,7 @@ fun CalendarMonthView( } val eventsByDay by remember(notes) { derivedStateOf { groupByDayKeyExpanded(notes) } } + val barsByDay by remember(notes) { derivedStateOf { computeMonthGridBars(notes) } } var selectedDayKey by rememberSaveable { mutableStateOf(null) } @@ -136,7 +136,7 @@ fun CalendarMonthView( MonthGrid( visibleMonth = visibleMonth, today = today, - eventsByDay = eventsByDay, + barsByDay = barsByDay, selectedDayKey = selectedDayKey, onDayClick = { dayKey -> selectedDayKey = if (selectedDayKey == dayKey) null else dayKey @@ -176,7 +176,7 @@ private fun WeekdayHeader() { private fun MonthGrid( visibleMonth: YearMonth, today: LocalDate, - eventsByDay: Map>, + barsByDay: Map>, selectedDayKey: Long?, onDayClick: (Long) -> Unit, ) { @@ -196,16 +196,22 @@ private fun MonthGrid( if (dayNumber in 1..daysInMonth) { val date = visibleMonth.atDay(dayNumber) val dayKey = date.toEpochDay() + val cellBars = barsByDay[dayKey].orEmpty() DayCell( modifier = Modifier.weight(1f), dayNumber = dayNumber, isToday = isCurrentMonth && date == today, isSelected = selectedDayKey == dayKey, - eventCount = eventsByDay[dayKey]?.size ?: 0, + bars = cellBars, + // Anything past the visible lane cap collapses into a "+N" tail — + // keeps each cell readable when a day has more than three events. + extraEventCount = cellBars.count { it.lane >= MONTH_GRID_MAX_LANES }, + isWeekStart = c == 0, + isWeekEnd = c == 6, onClick = { onDayClick(dayKey) }, ) } else { - Box(modifier = Modifier.weight(1f).height(56.dp)) + Box(modifier = Modifier.weight(1f).height(MONTH_CELL_HEIGHT)) } } } @@ -213,13 +219,18 @@ private fun MonthGrid( } } +private val MONTH_CELL_HEIGHT = 72.dp + @Composable private fun DayCell( modifier: Modifier, dayNumber: Int, isToday: Boolean, isSelected: Boolean, - eventCount: Int, + bars: List, + extraEventCount: Int, + isWeekStart: Boolean, + isWeekEnd: Boolean, onClick: () -> Unit, ) { val bg = @@ -232,8 +243,11 @@ private fun DayCell( Box( modifier = modifier - .height(56.dp) - .padding(2.dp) + .height(MONTH_CELL_HEIGHT) + // Vertical-only padding so adjacent cells in a row touch horizontally — a + // multi-day bar that extends from the right edge of one cell to the left edge of + // the next visually merges into a single uninterrupted line. + .padding(vertical = 2.dp) .background(bg, RoundedCornerShape(8.dp)) .border( width = if (isToday) 1.5.dp else 0.5.dp, @@ -242,9 +256,8 @@ private fun DayCell( ).clickable(onClick = onClick), ) { Column( - modifier = Modifier.fillMaxSize().padding(4.dp), + modifier = Modifier.fillMaxSize().padding(horizontal = 2.dp, vertical = 4.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.SpaceBetween, ) { Text( text = dayNumber.toString(), @@ -257,35 +270,69 @@ private fun DayCell( MaterialTheme.colorScheme.onSurface }, ) - EventDotRow(eventCount) + Spacer(modifier = Modifier.height(2.dp)) + EventBarLanes( + bars = bars, + extraEventCount = extraEventCount, + isWeekStart = isWeekStart, + isWeekEnd = isWeekEnd, + ) } } } +/** + * Renders up to [MONTH_GRID_MAX_LANES] horizontal bars stacked vertically. Each lane occupies a + * fixed height across every cell so a multi-day event sits on the same y-row in every column it + * covers — the visual continuity that makes "spans 3 days" readable at a glance. + * + * The bar is rounded only at the event's start (`isLeftEnd`) and end (`isRightEnd`). On week + * boundaries we also round so each row of the grid looks self-contained instead of bleeding into + * an unaligned next row. + */ @Composable -private fun EventDotRow(eventCount: Int) { - if (eventCount <= 0) { - Spacer(modifier = Modifier.height(6.dp)) - return - } - Row( - horizontalArrangement = Arrangement.spacedBy(2.dp), - modifier = Modifier.padding(bottom = 1.dp), +private fun EventBarLanes( + bars: List, + extraEventCount: Int, + isWeekStart: Boolean, + isWeekEnd: Boolean, +) { + val barColor = MaterialTheme.colorScheme.primary + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(1.dp), ) { - repeat(eventCount.coerceAtMost(3)) { - Box( - modifier = - Modifier - .size(5.dp) - .background(MaterialTheme.colorScheme.primary, CircleShape), - ) + for (i in 0 until MONTH_GRID_MAX_LANES) { + val seg = bars.firstOrNull { it.lane == i } + if (seg != null) { + val roundLeft = seg.isLeftEnd || isWeekStart + val roundRight = seg.isRightEnd || isWeekEnd + Box( + modifier = + Modifier + .fillMaxWidth() + .height(5.dp) + .background( + color = barColor, + shape = + RoundedCornerShape( + topStart = if (roundLeft) 2.dp else 0.dp, + bottomStart = if (roundLeft) 2.dp else 0.dp, + topEnd = if (roundRight) 2.dp else 0.dp, + bottomEnd = if (roundRight) 2.dp else 0.dp, + ), + ), + ) + } else { + Spacer(modifier = Modifier.fillMaxWidth().height(5.dp)) + } } - if (eventCount > 3) { + if (extraEventCount > 0) { Text( - text = "+", + text = "+$extraEventCount", style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.graphicsLayer { translationY = -3f }, + color = barColor, + fontWeight = FontWeight.SemiBold, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/dal/MonthGridBars.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/dal/MonthGridBars.kt new file mode 100644 index 000000000..3a34b1d25 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/dal/MonthGridBars.kt @@ -0,0 +1,98 @@ +/* + * 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 + +/** + * One bar drawn into one day cell, with `lane` controlling its vertical position so two + * overlapping multi-day events stack rather than collide. `isLeftEnd` / `isRightEnd` control + * which corners of the bar are rounded — a continuation day in the middle of a 3-day event gets + * neither end rounded, so adjacent cells visually merge into one bar. + * + * Note: the underlying [Note] is exposed so the UI can colour or label bars per event. Equality + * is on idHex so a row of cells holding the same bar share segment identity for keys. + */ +@Immutable +data class MonthGridBarSegment( + val note: Note, + val lane: Int, + val isLeftEnd: Boolean, + val isRightEnd: Boolean, +) + +/** + * Maximum lanes we render before collapsing the remainder into a "+N" overflow label. Three + * matches the previous dot-row capacity and keeps each 56dp cell readable on mid-range phones. + */ +const val MONTH_GRID_MAX_LANES = 3 + +/** + * Greedy lane-assignment for the month grid: sort events earliest-start-first (longer events + * wins ties so they take the top lane), then for each event pick the lowest lane index whose + * full day-range is unoccupied. Returns a per-day-key map so each cell can render its own + * segments without re-running the layout. + * + * Single-day events participate in the same layout — they get bars too, just short ones, + * which keeps the visual language consistent. + */ +fun computeMonthGridBars(notes: List): Map> { + val ranges = + notes + .distinctBy { it.idHex } + .mapNotNull { n -> n.calendarLocalDayKeyRange()?.let { n to it } } + .sortedWith( + compareBy( + { it.second.first }, + { -(it.second.last - it.second.first) }, + { it.first.idHex }, + ), + ) + + // day-key → set of lanes already claimed for that day + val occupied = mutableMapOf>() + val perDay = mutableMapOf>() + + for ((note, range) in ranges) { + // Find the lowest lane index whose full range is free. Bounded at 32 so a pathological + // input can't loop forever; overflow events still render as "+N" via the cap downstream. + var lane = 0 + while (lane < 32) { + val clash = (range).any { occupied[it]?.contains(lane) == true } + if (!clash) break + lane++ + } + for (day in range) { + occupied.getOrPut(day) { mutableSetOf() }.add(lane) + perDay.getOrPut(day) { mutableListOf() }.add( + MonthGridBarSegment( + note = note, + lane = lane, + isLeftEnd = day == range.first, + isRightEnd = day == range.last, + ), + ) + } + } + // Within each cell, sort by lane so the rendering doesn't have to. + return perDay.mapValues { (_, list) -> list.sortedBy { it.lane } } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/CalendarEventDetailScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/CalendarEventDetailScreen.kt index 9db6d06f2..790a56a42 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/CalendarEventDetailScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/CalendarEventDetailScreen.kt @@ -72,6 +72,7 @@ import com.vitorpamplona.amethyst.ui.note.ReactionsRow import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.note.types.CalendarRsvpRow import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.addToPhoneCalendar import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.IcsExport import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.appointmentView import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.CalendarsFilterAssemblerSubscription @@ -153,6 +154,24 @@ fun CalendarEventDetailScreen( // in posts, in other clients). A single button with a chooser would be // cleaner, but two icons keep both actions one tap away. if (event != null) { + // Direct "Add to phone calendar" — opens the system event composer with + // every field pre-filled. Falls back to the .ics share path if the device + // has no calendar app registered for ACTION_INSERT (rare; Wear OS, some + // GrapheneOS profiles). + IconButton(onClick = { + if (!addToPhoneCalendar(context, event)) { + val ics = IcsExport.appointmentToIcs(event, targetAddress, TimeUtils.now()) + val filename = IcsExport.appointmentFilename(event, targetAddress) + shareIcs(context, filename, ics) + } + }) { + Icon( + symbol = MaterialSymbols.EventAvailable, + contentDescription = stringRes(R.string.calendar_add_to_phone_calendar), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurface, + ) + } IconButton(onClick = { val ics = IcsExport.appointmentToIcs(event, targetAddress, TimeUtils.now()) val filename = IcsExport.appointmentFilename(event, targetAddress) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 26b9e7a8f..85674fe4b 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1968,6 +1968,7 @@ No events Continues Day %1$d of %2$d + Add to phone calendar (untitled) All-day ✓ Going diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/MonthGridBarsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/MonthGridBarsTest.kt new file mode 100644 index 000000000..c6617dd47 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/MonthGridBarsTest.kt @@ -0,0 +1,129 @@ +/* + * 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.calendar + +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.computeMonthGridBars +import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.LocalDate + +class MonthGridBarsTest { + @Test + fun singleDayEvent_oneSegment_bothEndsRounded() { + val note = dateSlot("a", "2025-01-15") + val key = LocalDate.of(2025, 1, 15).toEpochDay() + val byDay = computeMonthGridBars(listOf(note)) + val seg = byDay[key]?.single() + assertNotNull(seg) + assertTrue("single-day event should round both ends", seg!!.isLeftEnd && seg.isRightEnd) + assertEquals(0, seg.lane) + } + + @Test + fun threeDayEvent_segmentPerDay_endsOnlyOnBoundaries() { + val note = dateSlot("a", "2025-01-15", end = "2025-01-17") + val byDay = computeMonthGridBars(listOf(note)) + val k15 = LocalDate.of(2025, 1, 15).toEpochDay() + val k16 = LocalDate.of(2025, 1, 16).toEpochDay() + val k17 = LocalDate.of(2025, 1, 17).toEpochDay() + assertEquals(true to false, byDay[k15]!!.single().run { isLeftEnd to isRightEnd }) + assertEquals(false to false, byDay[k16]!!.single().run { isLeftEnd to isRightEnd }) + assertEquals(false to true, byDay[k17]!!.single().run { isLeftEnd to isRightEnd }) + } + + @Test + fun overlappingEvents_assignedToDistinctLanes() { + // A: Jan 15–17. B: Jan 16–18. They overlap on 16 and 17 so must land in different lanes. + val a = dateSlot("a", "2025-01-15", end = "2025-01-17") + val b = dateSlot("b", "2025-01-16", end = "2025-01-18") + val byDay = computeMonthGridBars(listOf(a, b)) + val k16 = LocalDate.of(2025, 1, 16).toEpochDay() + val lanes = byDay[k16]!!.map { it.lane }.toSet() + assertEquals("expected two distinct lanes on the overlap day", 2, lanes.size) + } + + @Test + fun longerEventTakesLowerLane_amongTies() { + // Earliest-start ties broken by length-descending: the longer event sits on lane 0 so it + // visually anchors the top, with the shorter one tucked under it. + val long3 = dateSlot("L", "2025-01-15", end = "2025-01-17") + val short1 = dateSlot("S", "2025-01-15") + val byDay = computeMonthGridBars(listOf(short1, long3)) + val k15 = byDay[LocalDate.of(2025, 1, 15).toEpochDay()]!! + val longLane = k15.first { it.note === long3 }.lane + val shortLane = k15.first { it.note === short1 }.lane + assertTrue("longer event should be in a lower lane", longLane < shortLane) + } + + @Test + fun nonOverlappingEvents_reuseLowestLane() { + // A: Jan 15. B: Jan 16. C: Jan 17. No overlaps → all on lane 0. + val a = dateSlot("a", "2025-01-15") + val b = dateSlot("b", "2025-01-16") + val c = dateSlot("c", "2025-01-17") + val byDay = computeMonthGridBars(listOf(a, b, c)) + for (note in listOf(a, b, c)) { + val key = + note.event!! + .tags + .first { it[0] == "start" }[1] + .let(LocalDate::parse) + .toEpochDay() + assertEquals(0, byDay[key]!!.single().lane) + } + } + + @Test + fun noEvents_emptyMap() { + val byDay = computeMonthGridBars(emptyList()) + assertTrue(byDay.isEmpty()) + } + + @Test + fun noteWithoutStart_dropped() { + val ghost = Note("ghost") // no event + val real = dateSlot("a", "2025-01-15") + val byDay = computeMonthGridBars(listOf(ghost, real)) + assertEquals(1, byDay.size) + assertNull(byDay[0L]) + } + + private fun dateSlot( + id: String, + start: String, + end: String? = null, + ): Note { + val tags = + buildList { + add(arrayOf("d", "$id-d")) + add(arrayOf("title", "T")) + add(arrayOf("start", start)) + end?.let { add(arrayOf("end", it)) } + }.toTypedArray() + val e = CalendarDateSlotEvent(id, "pub", 0L, tags, "", "sig") + return Note(id).apply { event = e } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt index 74bd0fae8..1530c9af5 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt @@ -97,6 +97,7 @@ object MaterialSymbols { val EmojiEmotions = MaterialSymbol("\uEA22") val Error = MaterialSymbol("\uF8B6") val ErrorOutline = MaterialSymbol("\uF8B6") + val EventAvailable = MaterialSymbol("\uE614") val ExpandLess = MaterialSymbol("\uE5CE") val ExpandMore = MaterialSymbol("\uE5CF") val Explore = MaterialSymbol("\uE87A")