feat(detail): full event read — surface every readable field (v0.6.0)

Round out the read-only model so the detail view shows everything
CalendarContract actually stores, ahead of write support.

Data layer:
- New domain types: Reminder, EventStatus, Availability, AccessLevel,
  AttendeeRelationship, AttendeeType; EventDetail gains reminders, status,
  availability, accessLevel, eventTimezone, selfStatus and Attendee gains
  relationship + type (all defaulted so existing callers compile)
- EventDetailProjection reads STATUS / AVAILABILITY / ACCESS_LEVEL /
  EVENT_TIMEZONE / SELF_ATTENDEE_STATUS; AttendeeProjection reads
  RELATIONSHIP + TYPE; new ReminderProjection queries CalendarContract.Reminders
- Mappers translate each provider integer code, guarding STATUS's null-vs-0
  ambiguity (0 == TENTATIVE) so an absent status reads as Confirmed
- Mapper unit tests cover every new column's codes

Detail UI:
- Status / availability / access chips under the title; cancelled also strikes
  the title through
- Reminders card with humanised lead times (plurals, DE + EN)
- Foreign-timezone card, shown only for timed events in a non-device zone
- Attendee role badges + the user's own "Your response: …" line
- http(s) URLs in the description are now tappable

URL field cut: CalendarContract exposes no Events.URL column (only the
CUSTOM_APP_URI app deep-link), so URLs are surfaced by linkifying the
description instead. Recorded in ROADMAP/CHANGELOG.

Version bumped to 0.6.0 / 6.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-11 08:56:40 +02:00
parent e78da3d7c1
commit 024512959f
12 changed files with 627 additions and 31 deletions

View File

@@ -1,7 +1,14 @@
package de.jeanlucmakiola.calendula.data.calendar
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.AttendeeRelationship
import de.jeanlucmakiola.calendula.domain.AttendeeStatus
import de.jeanlucmakiola.calendula.domain.AttendeeType
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.Reminder
import de.jeanlucmakiola.calendula.domain.ReminderMethod
import org.junit.jupiter.api.Test
class EventDetailMapperTest {
@@ -19,6 +26,11 @@ class EventDetailMapperTest {
allDay: Int = 0,
location: String? = "Berlin",
calendarId: Long = 7L,
status: Any? = null,
availability: Any? = null,
accessLevel: Any? = null,
timezone: String? = null,
selfStatus: Any? = null,
): MapColumnReader = MapColumnReader(
EventDetailProjection.IDX_EVENT_ID to eventId,
EventDetailProjection.IDX_TITLE to title,
@@ -32,18 +44,42 @@ class EventDetailMapperTest {
EventDetailProjection.IDX_ALL_DAY to allDay,
EventDetailProjection.IDX_LOCATION to location,
EventDetailProjection.IDX_CALENDAR_ID to calendarId,
EventDetailProjection.IDX_STATUS to status,
EventDetailProjection.IDX_AVAILABILITY to availability,
EventDetailProjection.IDX_ACCESS_LEVEL to accessLevel,
EventDetailProjection.IDX_EVENT_TIMEZONE to timezone,
EventDetailProjection.IDX_SELF_ATTENDEE_STATUS to selfStatus,
)
private fun attendeeReader(name: String?, email: String?, status: Int): MapColumnReader =
private fun attendeeReader(
name: String?,
email: String?,
status: Int,
relationship: Int = 0,
type: Int = 0,
): MapColumnReader =
MapColumnReader(
AttendeeProjection.IDX_NAME to name,
AttendeeProjection.IDX_EMAIL to email,
AttendeeProjection.IDX_STATUS to status,
AttendeeProjection.IDX_RELATIONSHIP to relationship,
AttendeeProjection.IDX_TYPE to type,
)
private fun reminderReader(minutes: Int, method: Int): MapColumnReader =
MapColumnReader(
ReminderProjection.IDX_MINUTES to minutes,
ReminderProjection.IDX_METHOD to method,
)
private fun MapColumnReader.toDetail(
attendees: List<de.jeanlucmakiola.calendula.domain.Attendee> = emptyList(),
reminders: List<Reminder> = emptyList(),
) = toEventDetailCore(attendees, reminders)
@Test
fun `happy path detail maps all fields and embeds matching EventInstance`() {
val detail = detailReader().toEventDetailCore(attendees = emptyList())
val detail = detailReader().toDetail()
assertThat(detail).isNotNull()
assertThat(detail!!.description).isEqualTo("Body")
assertThat(detail.organizer).isEqualTo("x@y")
@@ -55,21 +91,19 @@ class EventDetailMapperTest {
@Test
fun `event color falls back to calendar color when null`() {
val detail = detailReader(eventColor = null, calendarColor = 0xFF112233.toInt())
.toEventDetailCore(attendees = emptyList())
.toDetail()
assertThat(detail!!.instance.color).isEqualTo(0xFF112233.toInt())
}
@Test
fun `dtend before dtstart drops detail`() {
val detail = detailReader(dtstart = 2000L, dtend = 1000L)
.toEventDetailCore(attendees = emptyList())
val detail = detailReader(dtstart = 2000L, dtend = 1000L).toDetail()
assertThat(detail).isNull()
}
@Test
fun `rrule passes through when present`() {
val detail = detailReader(rrule = "FREQ=WEEKLY;BYDAY=MO")
.toEventDetailCore(attendees = emptyList())
val detail = detailReader(rrule = "FREQ=WEEKLY;BYDAY=MO").toDetail()
assertThat(detail!!.rrule).isEqualTo("FREQ=WEEKLY;BYDAY=MO")
}
@@ -104,4 +138,82 @@ class EventDetailMapperTest {
assertThat(attendeeReader("A", null, 1).toAttendee().email).isNull()
assertThat(attendeeReader("A", "x@y", 1).toAttendee().email).isEqualTo("x@y")
}
// RELATIONSHIP: NONE=0, ATTENDEE=1, ORGANIZER=2, PERFORMER=3, SPEAKER=4
@Test
fun `attendee relationship maps known integer codes`() {
assertThat(attendeeReader("A", "a@x", 1, relationship = 2).toAttendee().relationship)
.isEqualTo(AttendeeRelationship.Organizer)
assertThat(attendeeReader("A", "a@x", 1, relationship = 1).toAttendee().relationship)
.isEqualTo(AttendeeRelationship.Attendee)
assertThat(attendeeReader("A", "a@x", 1, relationship = 0).toAttendee().relationship)
.isEqualTo(AttendeeRelationship.None)
}
// TYPE: NONE=0, REQUIRED=1, OPTIONAL=2, RESOURCE=3
@Test
fun `attendee type maps known integer codes`() {
assertThat(attendeeReader("A", "a@x", 1, type = 1).toAttendee().type)
.isEqualTo(AttendeeType.Required)
assertThat(attendeeReader("A", "a@x", 1, type = 2).toAttendee().type)
.isEqualTo(AttendeeType.Optional)
assertThat(attendeeReader("A", "a@x", 1, type = 3).toAttendee().type)
.isEqualTo(AttendeeType.Resource)
}
// STATUS: TENTATIVE=0, CONFIRMED=1, CANCELED=2
@Test
fun `event status null maps to confirmed, codes map through`() {
assertThat(detailReader(status = null).toDetail()!!.status).isEqualTo(EventStatus.Confirmed)
assertThat(detailReader(status = 0).toDetail()!!.status).isEqualTo(EventStatus.Tentative)
assertThat(detailReader(status = 1).toDetail()!!.status).isEqualTo(EventStatus.Confirmed)
assertThat(detailReader(status = 2).toDetail()!!.status).isEqualTo(EventStatus.Cancelled)
}
// AVAILABILITY: BUSY=0, FREE=1, TENTATIVE=2
@Test
fun `availability null or busy maps to Busy, free maps to Free`() {
assertThat(detailReader(availability = null).toDetail()!!.availability)
.isEqualTo(Availability.Busy)
assertThat(detailReader(availability = 0).toDetail()!!.availability)
.isEqualTo(Availability.Busy)
assertThat(detailReader(availability = 1).toDetail()!!.availability)
.isEqualTo(Availability.Free)
}
// ACCESS_LEVEL: DEFAULT=0, CONFIDENTIAL=1, PRIVATE=2, PUBLIC=3
@Test
fun `access level maps known integer codes, null is Default`() {
assertThat(detailReader(accessLevel = null).toDetail()!!.accessLevel)
.isEqualTo(AccessLevel.Default)
assertThat(detailReader(accessLevel = 1).toDetail()!!.accessLevel)
.isEqualTo(AccessLevel.Confidential)
assertThat(detailReader(accessLevel = 2).toDetail()!!.accessLevel)
.isEqualTo(AccessLevel.Private)
}
@Test
fun `event timezone and self status pass through`() {
val detail = detailReader(timezone = "Europe/Berlin", selfStatus = 1).toDetail()
assertThat(detail!!.eventTimezone).isEqualTo("Europe/Berlin")
assertThat(detail.selfStatus).isEqualTo(AttendeeStatus.Accepted)
}
@Test
fun `reminders pass through to the detail`() {
val reminders = listOf(Reminder(10, ReminderMethod.Alert))
val detail = detailReader().toDetail(reminders = reminders)
assertThat(detail!!.reminders).isEqualTo(reminders)
}
// METHOD: DEFAULT=0, ALERT=1, EMAIL=2, SMS=3, ALARM=4
@Test
fun `reminder maps minutes and method codes`() {
assertThat(reminderReader(10, 1).toReminder())
.isEqualTo(Reminder(10, ReminderMethod.Alert))
assertThat(reminderReader(60, 2).toReminder())
.isEqualTo(Reminder(60, ReminderMethod.Email))
assertThat(reminderReader(0, 0).toReminder())
.isEqualTo(Reminder(0, ReminderMethod.Default))
}
}