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:
@@ -23,8 +23,8 @@ android {
|
||||
applicationId = "de.jeanlucmakiola.calendula"
|
||||
minSdk = 29
|
||||
targetSdk = 36
|
||||
versionCode = 5
|
||||
versionName = "0.5.0"
|
||||
versionCode = 6
|
||||
versionName = "0.6.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import de.jeanlucmakiola.calendula.domain.Attendee
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -62,13 +63,14 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
|
||||
override fun eventDetail(eventId: Long): EventDetail? {
|
||||
val attendees = queryAttendees(eventId)
|
||||
val reminders = queryReminders(eventId)
|
||||
return resolver.query(
|
||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
||||
EventDetailProjection.COLUMNS,
|
||||
null, null, null,
|
||||
)?.use { c ->
|
||||
if (!c.moveToFirst()) null
|
||||
else CursorColumnReader(c).toEventDetailCore(attendees)
|
||||
else CursorColumnReader(c).toEventDetailCore(attendees, reminders)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +100,14 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
null,
|
||||
)?.use { c -> c.mapAll { CursorColumnReader(c).toAttendee() } } ?: emptyList()
|
||||
|
||||
private fun queryReminders(eventId: Long): List<Reminder> = resolver.query(
|
||||
CalendarContract.Reminders.CONTENT_URI,
|
||||
ReminderProjection.COLUMNS,
|
||||
CalendarContract.Reminders.EVENT_ID + " = ?",
|
||||
arrayOf(eventId.toString()),
|
||||
null,
|
||||
)?.use { c -> c.mapAll { CursorColumnReader(c).toReminder() } } ?: emptyList()
|
||||
|
||||
private fun toCalendarSource(c: Cursor): CalendarSource = CursorColumnReader(c).toCalendarSource()
|
||||
|
||||
/** Iterate every row and map; skips nothing. */
|
||||
|
||||
@@ -2,14 +2,24 @@ package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import android.provider.CalendarContract
|
||||
import android.util.Log
|
||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||
import de.jeanlucmakiola.calendula.domain.Attendee
|
||||
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.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||
import de.jeanlucmakiola.calendula.domain.ReminderMethod
|
||||
|
||||
private const val TAG = "EventDetailMapper"
|
||||
|
||||
internal fun ColumnReader.toEventDetailCore(attendees: List<Attendee>): EventDetail? {
|
||||
internal fun ColumnReader.toEventDetailCore(
|
||||
attendees: List<Attendee>,
|
||||
reminders: List<Reminder>,
|
||||
): EventDetail? {
|
||||
val begin = getLong(EventDetailProjection.IDX_DTSTART)
|
||||
|
||||
if (begin < 0L) {
|
||||
@@ -54,12 +64,28 @@ internal fun ColumnReader.toEventDetailCore(attendees: List<Attendee>): EventDet
|
||||
location = getString(EventDetailProjection.IDX_LOCATION),
|
||||
)
|
||||
|
||||
// STATUS shares its 0 value with STATUS_TENTATIVE, so a missing column must
|
||||
// be distinguished from a present 0 — an absent status means "just confirmed".
|
||||
val status = if (isNull(EventDetailProjection.IDX_STATUS)) {
|
||||
EventStatus.Confirmed
|
||||
} else {
|
||||
mapEventStatus(getInt(EventDetailProjection.IDX_STATUS))
|
||||
}
|
||||
|
||||
return EventDetail(
|
||||
instance = instance,
|
||||
description = getString(EventDetailProjection.IDX_DESCRIPTION),
|
||||
organizer = getString(EventDetailProjection.IDX_ORGANIZER),
|
||||
attendees = attendees,
|
||||
rrule = getString(EventDetailProjection.IDX_RRULE),
|
||||
reminders = reminders,
|
||||
status = status,
|
||||
// BUSY and DEFAULT are both 0, so a null column folds into the same
|
||||
// default these mappers already return — no isNull guard needed.
|
||||
availability = mapAvailability(getInt(EventDetailProjection.IDX_AVAILABILITY)),
|
||||
accessLevel = mapAccessLevel(getInt(EventDetailProjection.IDX_ACCESS_LEVEL)),
|
||||
eventTimezone = getString(EventDetailProjection.IDX_EVENT_TIMEZONE),
|
||||
selfStatus = mapAttendeeStatus(getInt(EventDetailProjection.IDX_SELF_ATTENDEE_STATUS)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -67,6 +93,13 @@ internal fun ColumnReader.toAttendee(): Attendee = Attendee(
|
||||
name = getString(AttendeeProjection.IDX_NAME).orEmpty(),
|
||||
email = getString(AttendeeProjection.IDX_EMAIL),
|
||||
status = mapAttendeeStatus(getInt(AttendeeProjection.IDX_STATUS)),
|
||||
relationship = mapAttendeeRelationship(getInt(AttendeeProjection.IDX_RELATIONSHIP)),
|
||||
type = mapAttendeeType(getInt(AttendeeProjection.IDX_TYPE)),
|
||||
)
|
||||
|
||||
internal fun ColumnReader.toReminder(): Reminder = Reminder(
|
||||
minutes = getInt(ReminderProjection.IDX_MINUTES),
|
||||
method = mapReminderMethod(getInt(ReminderProjection.IDX_METHOD)),
|
||||
)
|
||||
|
||||
internal fun mapAttendeeStatus(raw: Int): AttendeeStatus = when (raw) {
|
||||
@@ -76,3 +109,46 @@ internal fun mapAttendeeStatus(raw: Int): AttendeeStatus = when (raw) {
|
||||
CalendarContract.Attendees.ATTENDEE_STATUS_INVITED -> AttendeeStatus.NeedsAction
|
||||
else -> AttendeeStatus.Unknown
|
||||
}
|
||||
|
||||
internal fun mapAttendeeRelationship(raw: Int): AttendeeRelationship = when (raw) {
|
||||
CalendarContract.Attendees.RELATIONSHIP_ORGANIZER -> AttendeeRelationship.Organizer
|
||||
CalendarContract.Attendees.RELATIONSHIP_PERFORMER -> AttendeeRelationship.Performer
|
||||
CalendarContract.Attendees.RELATIONSHIP_SPEAKER -> AttendeeRelationship.Speaker
|
||||
CalendarContract.Attendees.RELATIONSHIP_ATTENDEE -> AttendeeRelationship.Attendee
|
||||
else -> AttendeeRelationship.None
|
||||
}
|
||||
|
||||
internal fun mapAttendeeType(raw: Int): AttendeeType = when (raw) {
|
||||
CalendarContract.Attendees.TYPE_REQUIRED -> AttendeeType.Required
|
||||
CalendarContract.Attendees.TYPE_OPTIONAL -> AttendeeType.Optional
|
||||
CalendarContract.Attendees.TYPE_RESOURCE -> AttendeeType.Resource
|
||||
else -> AttendeeType.None
|
||||
}
|
||||
|
||||
internal fun mapEventStatus(raw: Int): EventStatus = when (raw) {
|
||||
CalendarContract.Events.STATUS_CONFIRMED -> EventStatus.Confirmed
|
||||
CalendarContract.Events.STATUS_TENTATIVE -> EventStatus.Tentative
|
||||
CalendarContract.Events.STATUS_CANCELED -> EventStatus.Cancelled
|
||||
else -> EventStatus.Confirmed
|
||||
}
|
||||
|
||||
internal fun mapAvailability(raw: Int): Availability = when (raw) {
|
||||
CalendarContract.Events.AVAILABILITY_FREE -> Availability.Free
|
||||
CalendarContract.Events.AVAILABILITY_TENTATIVE -> Availability.Tentative
|
||||
else -> Availability.Busy
|
||||
}
|
||||
|
||||
internal fun mapAccessLevel(raw: Int): AccessLevel = when (raw) {
|
||||
CalendarContract.Events.ACCESS_CONFIDENTIAL -> AccessLevel.Confidential
|
||||
CalendarContract.Events.ACCESS_PRIVATE -> AccessLevel.Private
|
||||
CalendarContract.Events.ACCESS_PUBLIC -> AccessLevel.Public
|
||||
else -> AccessLevel.Default
|
||||
}
|
||||
|
||||
internal fun mapReminderMethod(raw: Int): ReminderMethod = when (raw) {
|
||||
CalendarContract.Reminders.METHOD_EMAIL -> ReminderMethod.Email
|
||||
CalendarContract.Reminders.METHOD_SMS -> ReminderMethod.Sms
|
||||
CalendarContract.Reminders.METHOD_ALARM -> ReminderMethod.Alarm
|
||||
CalendarContract.Reminders.METHOD_ALERT -> ReminderMethod.Alert
|
||||
else -> ReminderMethod.Default
|
||||
}
|
||||
|
||||
@@ -60,6 +60,11 @@ internal object EventDetailProjection {
|
||||
CalendarContract.Events.ALL_DAY,
|
||||
CalendarContract.Events.EVENT_LOCATION,
|
||||
CalendarContract.Events.CALENDAR_ID,
|
||||
CalendarContract.Events.STATUS,
|
||||
CalendarContract.Events.AVAILABILITY,
|
||||
CalendarContract.Events.ACCESS_LEVEL,
|
||||
CalendarContract.Events.EVENT_TIMEZONE,
|
||||
CalendarContract.Events.SELF_ATTENDEE_STATUS,
|
||||
)
|
||||
|
||||
const val IDX_EVENT_ID = 0
|
||||
@@ -74,6 +79,11 @@ internal object EventDetailProjection {
|
||||
const val IDX_ALL_DAY = 9
|
||||
const val IDX_LOCATION = 10
|
||||
const val IDX_CALENDAR_ID = 11
|
||||
const val IDX_STATUS = 12
|
||||
const val IDX_AVAILABILITY = 13
|
||||
const val IDX_ACCESS_LEVEL = 14
|
||||
const val IDX_EVENT_TIMEZONE = 15
|
||||
const val IDX_SELF_ATTENDEE_STATUS = 16
|
||||
}
|
||||
|
||||
internal object AttendeeProjection {
|
||||
@@ -81,11 +91,25 @@ internal object AttendeeProjection {
|
||||
CalendarContract.Attendees.ATTENDEE_NAME,
|
||||
CalendarContract.Attendees.ATTENDEE_EMAIL,
|
||||
CalendarContract.Attendees.ATTENDEE_STATUS,
|
||||
CalendarContract.Attendees.ATTENDEE_RELATIONSHIP,
|
||||
CalendarContract.Attendees.ATTENDEE_TYPE,
|
||||
)
|
||||
|
||||
const val IDX_NAME = 0
|
||||
const val IDX_EMAIL = 1
|
||||
const val IDX_STATUS = 2
|
||||
const val IDX_RELATIONSHIP = 3
|
||||
const val IDX_TYPE = 4
|
||||
}
|
||||
|
||||
internal object ReminderProjection {
|
||||
val COLUMNS: Array<String> = arrayOf(
|
||||
CalendarContract.Reminders.MINUTES,
|
||||
CalendarContract.Reminders.METHOD,
|
||||
)
|
||||
|
||||
const val IDX_MINUTES = 0
|
||||
const val IDX_METHOD = 1
|
||||
}
|
||||
|
||||
internal object Fallbacks {
|
||||
|
||||
@@ -29,12 +29,34 @@ data class EventDetail(
|
||||
val organizer: String?,
|
||||
val attendees: List<Attendee>,
|
||||
val rrule: String?,
|
||||
/** Reminders (VALARM) configured on the event, ascending lead time. */
|
||||
val reminders: List<Reminder> = emptyList(),
|
||||
/** Confirmed / Tentative / Cancelled (`Events.STATUS`). */
|
||||
val status: EventStatus = EventStatus.Confirmed,
|
||||
/** Busy / Free (`Events.AVAILABILITY`, the iCal TRANSP field). */
|
||||
val availability: Availability = Availability.Busy,
|
||||
/** Default / Private / Confidential / Public (`Events.ACCESS_LEVEL`). */
|
||||
val accessLevel: AccessLevel = AccessLevel.Default,
|
||||
/** Raw zone id (`Events.EVENT_TIMEZONE`), e.g. "Europe/Berlin"; null if unset. */
|
||||
val eventTimezone: String? = null,
|
||||
/** This device user's own response (`Events.SELF_ATTENDEE_STATUS`). */
|
||||
val selfStatus: AttendeeStatus = AttendeeStatus.Unknown,
|
||||
)
|
||||
|
||||
data class Attendee(
|
||||
val name: String,
|
||||
val email: String?,
|
||||
val status: AttendeeStatus,
|
||||
/** Organizer / performer / speaker / plain attendee (`ATTENDEE_RELATIONSHIP`). */
|
||||
val relationship: AttendeeRelationship = AttendeeRelationship.None,
|
||||
/** Required / optional / resource (`ATTENDEE_TYPE`). */
|
||||
val type: AttendeeType = AttendeeType.None,
|
||||
)
|
||||
|
||||
data class Reminder(
|
||||
/** Lead time before the event start, in minutes. `-1` means the provider default. */
|
||||
val minutes: Int,
|
||||
val method: ReminderMethod,
|
||||
)
|
||||
|
||||
enum class AttendeeStatus {
|
||||
@@ -45,6 +67,48 @@ enum class AttendeeStatus {
|
||||
Unknown,
|
||||
}
|
||||
|
||||
enum class AttendeeRelationship {
|
||||
Organizer,
|
||||
Attendee,
|
||||
Performer,
|
||||
Speaker,
|
||||
None,
|
||||
}
|
||||
|
||||
enum class AttendeeType {
|
||||
Required,
|
||||
Optional,
|
||||
Resource,
|
||||
None,
|
||||
}
|
||||
|
||||
enum class ReminderMethod {
|
||||
Alert,
|
||||
Email,
|
||||
Sms,
|
||||
Alarm,
|
||||
Default,
|
||||
}
|
||||
|
||||
enum class EventStatus {
|
||||
Confirmed,
|
||||
Tentative,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
enum class Availability {
|
||||
Busy,
|
||||
Free,
|
||||
Tentative,
|
||||
}
|
||||
|
||||
enum class AccessLevel {
|
||||
Default,
|
||||
Public,
|
||||
Private,
|
||||
Confidential,
|
||||
}
|
||||
|
||||
enum class FailureReason {
|
||||
PermissionRevoked,
|
||||
NoCalendarsConfigured,
|
||||
|
||||
@@ -13,6 +13,8 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -30,8 +32,10 @@ import androidx.compose.material.icons.automirrored.filled.Notes
|
||||
import androidx.compose.material.icons.filled.CalendarMonth
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.People
|
||||
import androidx.compose.material.icons.filled.Place
|
||||
import androidx.compose.material.icons.filled.Public
|
||||
import androidx.compose.material.icons.filled.Repeat
|
||||
import androidx.compose.material.icons.filled.Schedule
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -46,25 +50,36 @@ import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.LinkAnnotation
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLinkStyles
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||
import de.jeanlucmakiola.calendula.domain.Attendee
|
||||
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.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||
@@ -160,10 +175,16 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
||||
.padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 40.dp),
|
||||
) {
|
||||
// Title with a short accent line in the calendar colour underneath.
|
||||
// A cancelled event strikes through the title.
|
||||
Text(
|
||||
text = instance.title.ifBlank { stringResource(R.string.event_untitled) },
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
textDecoration = if (detail.status == EventStatus.Cancelled) {
|
||||
TextDecoration.LineThrough
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Box(
|
||||
@@ -173,6 +194,11 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
||||
.background(accent, RoundedCornerShape(2.dp)),
|
||||
)
|
||||
|
||||
// Status / availability / access chips. Availability is always known, so
|
||||
// this row always shows at least the Free/Busy chip.
|
||||
Spacer(Modifier.height(16.dp))
|
||||
StatusChips(detail.status, detail.availability, detail.accessLevel)
|
||||
|
||||
Spacer(Modifier.height(20.dp))
|
||||
|
||||
// Every piece of info shares one card design: a tonal container with a
|
||||
@@ -194,6 +220,18 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
||||
}
|
||||
}
|
||||
|
||||
// Time zone — only when the event is timed and pinned to a zone other
|
||||
// than the device's, so cross-zone events read unambiguously.
|
||||
foreignTimeZoneLabel(detail.eventTimezone, instance.isAllDay, locale)?.let { tzLabel ->
|
||||
Spacer(Modifier.height(gap))
|
||||
DetailCard(
|
||||
icon = Icons.Default.Public,
|
||||
iconContentDescription = stringResource(R.string.event_detail_timezone),
|
||||
) {
|
||||
Text(text = tzLabel, style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
}
|
||||
|
||||
// Calendar — icon tinted in the calendar colour conveys identity, so no
|
||||
// separate colour dot is needed.
|
||||
Spacer(Modifier.height(gap))
|
||||
@@ -228,28 +266,63 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
||||
}
|
||||
}
|
||||
|
||||
// Description (conditional).
|
||||
// Description (conditional). URLs are auto-linked.
|
||||
detail.description?.takeIf { it.isNotBlank() }?.let { description ->
|
||||
Spacer(Modifier.height(gap))
|
||||
DetailCard(
|
||||
icon = Icons.AutoMirrored.Filled.Notes,
|
||||
iconContentDescription = stringResource(R.string.event_detail_description),
|
||||
) {
|
||||
Text(text = description, style = MaterialTheme.typography.bodyMedium)
|
||||
Text(
|
||||
text = linkifyUrls(description, MaterialTheme.colorScheme.primary),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Attendees (conditional).
|
||||
// Attendees (conditional). The user's own response leads the list, then
|
||||
// each attendee with their role and reply.
|
||||
detail.attendees.takeIf { it.isNotEmpty() }?.let { attendees ->
|
||||
Spacer(Modifier.height(gap))
|
||||
DetailCard(
|
||||
icon = Icons.Default.People,
|
||||
iconContentDescription = stringResource(R.string.event_detail_attendees),
|
||||
) {
|
||||
if (detail.selfStatus != AttendeeStatus.Unknown) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.event_detail_self_response,
|
||||
stringResource(attendeeStatusLabel(detail.selfStatus)),
|
||||
),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
attendees.forEach { AttendeeRow(it) }
|
||||
}
|
||||
}
|
||||
|
||||
// Reminders (conditional) — list each lead time before the event.
|
||||
detail.reminders.takeIf { it.isNotEmpty() }?.let { reminders ->
|
||||
Spacer(Modifier.height(gap))
|
||||
DetailCard(
|
||||
icon = Icons.Default.Notifications,
|
||||
iconContentDescription = stringResource(R.string.event_detail_reminders),
|
||||
) {
|
||||
reminders
|
||||
.distinctBy { it.minutes }
|
||||
.sortedBy { it.minutes }
|
||||
.forEach { reminder ->
|
||||
Text(
|
||||
text = reminderLeadText(reminder),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recurrence (conditional) — humanised from the RRULE.
|
||||
detail.rrule?.takeIf { it.isNotBlank() }?.let { rrule ->
|
||||
Spacer(Modifier.height(gap))
|
||||
@@ -304,10 +377,20 @@ private fun AttendeeRow(attendee: Attendee) {
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = attendee.name.ifBlank { attendee.email.orEmpty() },
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = attendee.name.ifBlank { attendee.email.orEmpty() },
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
attendeeRoleLabel(attendee)?.let { roleRes ->
|
||||
Text(
|
||||
text = stringResource(roleRes),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
text = stringResource(attendeeStatusLabel(attendee.status)),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
@@ -316,6 +399,64 @@ private fun AttendeeRow(attendee: Attendee) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Status / availability / access pills shown directly under the title accent. */
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun StatusChips(
|
||||
status: EventStatus,
|
||||
availability: Availability,
|
||||
accessLevel: AccessLevel,
|
||||
) {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
when (status) {
|
||||
EventStatus.Cancelled -> InfoChip(
|
||||
text = stringResource(R.string.event_status_cancelled),
|
||||
container = MaterialTheme.colorScheme.errorContainer,
|
||||
content = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
EventStatus.Tentative -> InfoChip(
|
||||
text = stringResource(R.string.event_status_tentative),
|
||||
container = MaterialTheme.colorScheme.tertiaryContainer,
|
||||
content = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
)
|
||||
EventStatus.Confirmed -> Unit
|
||||
}
|
||||
|
||||
val availabilityLabel = if (availability == Availability.Free) {
|
||||
R.string.event_availability_free
|
||||
} else {
|
||||
R.string.event_availability_busy
|
||||
}
|
||||
InfoChip(text = stringResource(availabilityLabel))
|
||||
|
||||
when (accessLevel) {
|
||||
AccessLevel.Private -> InfoChip(text = stringResource(R.string.event_access_private))
|
||||
AccessLevel.Confidential ->
|
||||
InfoChip(text = stringResource(R.string.event_access_confidential))
|
||||
AccessLevel.Default, AccessLevel.Public -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoChip(
|
||||
text: String,
|
||||
container: Color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
content: Color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
) {
|
||||
Surface(color = container, shape = RoundedCornerShape(8.dp)) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = content,
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EventDetailLoading(modifier: Modifier = Modifier) {
|
||||
Column(modifier = modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp)) {
|
||||
@@ -361,6 +502,77 @@ private fun attendeeStatusLabel(status: AttendeeStatus): Int = when (status) {
|
||||
AttendeeStatus.Unknown -> R.string.event_attendee_unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* The role badge shown under an attendee's name. Organizer wins over type;
|
||||
* required attendees (the common case) get no badge to keep the list quiet.
|
||||
*/
|
||||
private fun attendeeRoleLabel(attendee: Attendee): Int? = when {
|
||||
attendee.relationship == AttendeeRelationship.Organizer -> R.string.event_attendee_organizer
|
||||
attendee.type == AttendeeType.Optional -> R.string.event_attendee_optional
|
||||
attendee.type == AttendeeType.Resource -> R.string.event_attendee_resource
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** Humanise a reminder's lead time, e.g. "10 minutes before" / "At time of event". */
|
||||
@Composable
|
||||
private fun reminderLeadText(reminder: Reminder): String {
|
||||
val minutes = reminder.minutes
|
||||
return when {
|
||||
minutes < 0 -> stringResource(R.string.reminder_default)
|
||||
minutes == 0 -> stringResource(R.string.reminder_at_time)
|
||||
minutes % 10_080 == 0 -> {
|
||||
val weeks = minutes / 10_080
|
||||
pluralStringResource(R.plurals.reminder_weeks, weeks, weeks)
|
||||
}
|
||||
minutes % 1_440 == 0 -> {
|
||||
val days = minutes / 1_440
|
||||
pluralStringResource(R.plurals.reminder_days, days, days)
|
||||
}
|
||||
minutes % 60 == 0 -> {
|
||||
val hours = minutes / 60
|
||||
pluralStringResource(R.plurals.reminder_hours, hours, hours)
|
||||
}
|
||||
else -> pluralStringResource(R.plurals.reminder_minutes, minutes, minutes)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A localized label for [tz] (e.g. "Central European Time (Europe/Berlin)"),
|
||||
* but only when the event is timed and pinned to a zone different from the
|
||||
* device's. Returns null when there's nothing worth showing.
|
||||
*/
|
||||
private fun foreignTimeZoneLabel(tz: String?, isAllDay: Boolean, locale: Locale): String? {
|
||||
if (isAllDay || tz.isNullOrBlank()) return null
|
||||
val deviceZone = ZoneId.systemDefault().id
|
||||
if (tz == deviceZone) return null
|
||||
return try {
|
||||
val zone = ZoneId.of(tz)
|
||||
val name = zone.getDisplayName(JavaTextStyle.FULL, locale)
|
||||
if (name == tz) tz else "$name ($tz)"
|
||||
} catch (e: Exception) {
|
||||
tz
|
||||
}
|
||||
}
|
||||
|
||||
/** Wrap http(s) URLs in [text] as tappable links tinted [linkColor]. */
|
||||
@Composable
|
||||
private fun linkifyUrls(text: String, linkColor: Color): AnnotatedString = remember(text, linkColor) {
|
||||
val regex = Regex("""https?://\S+""")
|
||||
val styles = TextLinkStyles(
|
||||
style = SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline),
|
||||
)
|
||||
buildAnnotatedString {
|
||||
append(text)
|
||||
for (match in regex.findAll(text)) {
|
||||
// Trim trailing punctuation that commonly abuts a URL in prose.
|
||||
val raw = match.value
|
||||
val url = raw.trimEnd('.', ',', ';', ':', '!', '?', ')', ']', '"', '\'')
|
||||
val end = match.range.first + url.length
|
||||
addLink(LinkAnnotation.Url(url, styles), match.range.first, end)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Humanise an RFC 5545 RRULE into a localized phrase, e.g.
|
||||
* "Every week on Tue and Thu until 31 Dec 2026" or "Every day, 10 times".
|
||||
|
||||
@@ -66,6 +66,38 @@
|
||||
<string name="event_attendee_needs_action">Keine Antwort</string>
|
||||
<string name="event_attendee_unknown">—</string>
|
||||
|
||||
<!-- Event-Detail — vollständiges Auslesen (v0.6) -->
|
||||
<string name="event_detail_reminders">Erinnerungen</string>
|
||||
<string name="event_detail_timezone">Zeitzone</string>
|
||||
<string name="event_status_tentative">Vorläufig</string>
|
||||
<string name="event_status_cancelled">Abgesagt</string>
|
||||
<string name="event_availability_free">Frei</string>
|
||||
<string name="event_availability_busy">Gebucht</string>
|
||||
<string name="event_access_private">Privat</string>
|
||||
<string name="event_access_confidential">Vertraulich</string>
|
||||
<string name="event_attendee_organizer">Organisator</string>
|
||||
<string name="event_attendee_optional">Optional</string>
|
||||
<string name="event_attendee_resource">Ressource</string>
|
||||
<string name="event_detail_self_response">Deine Antwort: %1$s</string>
|
||||
<string name="reminder_at_time">Zur Startzeit</string>
|
||||
<string name="reminder_default">Standarderinnerung</string>
|
||||
<plurals name="reminder_minutes">
|
||||
<item quantity="one">%d Minute vorher</item>
|
||||
<item quantity="other">%d Minuten vorher</item>
|
||||
</plurals>
|
||||
<plurals name="reminder_hours">
|
||||
<item quantity="one">%d Stunde vorher</item>
|
||||
<item quantity="other">%d Stunden vorher</item>
|
||||
</plurals>
|
||||
<plurals name="reminder_days">
|
||||
<item quantity="one">%d Tag vorher</item>
|
||||
<item quantity="other">%d Tage vorher</item>
|
||||
</plurals>
|
||||
<plurals name="reminder_weeks">
|
||||
<item quantity="one">%d Woche vorher</item>
|
||||
<item quantity="other">%d Wochen vorher</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Geteilte Event-Strings -->
|
||||
<string name="event_untitled">(Ohne Titel)</string>
|
||||
|
||||
|
||||
@@ -67,6 +67,38 @@
|
||||
<string name="event_attendee_needs_action">No response</string>
|
||||
<string name="event_attendee_unknown">—</string>
|
||||
|
||||
<!-- Event detail — full read (v0.6) -->
|
||||
<string name="event_detail_reminders">Reminders</string>
|
||||
<string name="event_detail_timezone">Time zone</string>
|
||||
<string name="event_status_tentative">Tentative</string>
|
||||
<string name="event_status_cancelled">Cancelled</string>
|
||||
<string name="event_availability_free">Free</string>
|
||||
<string name="event_availability_busy">Busy</string>
|
||||
<string name="event_access_private">Private</string>
|
||||
<string name="event_access_confidential">Confidential</string>
|
||||
<string name="event_attendee_organizer">Organizer</string>
|
||||
<string name="event_attendee_optional">Optional</string>
|
||||
<string name="event_attendee_resource">Resource</string>
|
||||
<string name="event_detail_self_response">Your response: %1$s</string>
|
||||
<string name="reminder_at_time">At time of event</string>
|
||||
<string name="reminder_default">Default reminder</string>
|
||||
<plurals name="reminder_minutes">
|
||||
<item quantity="one">%d minute before</item>
|
||||
<item quantity="other">%d minutes before</item>
|
||||
</plurals>
|
||||
<plurals name="reminder_hours">
|
||||
<item quantity="one">%d hour before</item>
|
||||
<item quantity="other">%d hours before</item>
|
||||
</plurals>
|
||||
<plurals name="reminder_days">
|
||||
<item quantity="one">%d day before</item>
|
||||
<item quantity="other">%d days before</item>
|
||||
</plurals>
|
||||
<plurals name="reminder_weeks">
|
||||
<item quantity="one">%d week before</item>
|
||||
<item quantity="other">%d weeks before</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Shared event strings -->
|
||||
<string name="event_untitled">(No title)</string>
|
||||
|
||||
|
||||
@@ -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