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

@@ -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. */

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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".