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:
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user