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.
This commit is contained in:
+103
@@ -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<Long, Long, Boolean>? =
|
||||
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
|
||||
}
|
||||
+76
-29
@@ -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<Long?>(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<Long, List<Note>>,
|
||||
barsByDay: Map<Long, List<MonthGridBarSegment>>,
|
||||
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<MonthGridBarSegment>,
|
||||
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<MonthGridBarSegment>,
|
||||
extraEventCount: Int,
|
||||
isWeekStart: Boolean,
|
||||
isWeekEnd: Boolean,
|
||||
) {
|
||||
repeat(eventCount.coerceAtMost(3)) {
|
||||
val barColor = MaterialTheme.colorScheme.primary
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(1.dp),
|
||||
) {
|
||||
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
|
||||
.size(5.dp)
|
||||
.background(MaterialTheme.colorScheme.primary, CircleShape),
|
||||
.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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+98
@@ -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<Note>): Map<Long, List<MonthGridBarSegment>> {
|
||||
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<Long, MutableSet<Int>>()
|
||||
val perDay = mutableMapOf<Long, MutableList<MonthGridBarSegment>>()
|
||||
|
||||
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 } }
|
||||
}
|
||||
+19
@@ -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)
|
||||
|
||||
@@ -1968,6 +1968,7 @@
|
||||
<string name="calendar_no_events">No events</string>
|
||||
<string name="calendar_continues">Continues</string>
|
||||
<string name="calendar_day_of_total">Day %1$d of %2$d</string>
|
||||
<string name="calendar_add_to_phone_calendar">Add to phone calendar</string>
|
||||
<string name="calendar_untitled">(untitled)</string>
|
||||
<string name="calendar_all_day">All-day</string>
|
||||
<string name="calendar_rsvp_going_prefixed">✓ Going</string>
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
+1
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user