feat: full event read (v0.6.0) + onboarding redesign, cut v1.0.0 #2
@@ -9,7 +9,7 @@
|
|||||||
| v0.3 | Month + Week + Day views, view switcher | complete |
|
| v0.3 | Month + Week + Day views, view switcher | complete |
|
||||||
| v0.4 | Event Detail (S4) + humanized recurrence | complete |
|
| v0.4 | Event Detail (S4) + humanized recurrence | complete |
|
||||||
| v0.5 | Calendar filter (M3) + Settings (M4) | complete |
|
| v0.5 | Calendar filter (M3) + Settings (M4) | complete |
|
||||||
| v0.6 | Full event read — surface every readable field | pending |
|
| v0.6 | Full event read — surface every readable field | complete |
|
||||||
| v1.0 | Polish pass, F-Droid release | pending |
|
| v1.0 | Polish pass, F-Droid release | pending |
|
||||||
|
|
||||||
Delivery ran ahead of the original table: Day view (S3) shipped in v0.3 and
|
Delivery ran ahead of the original table: Day view (S3) shipped in v0.3 and
|
||||||
@@ -30,9 +30,13 @@ columns we don't yet read/display:
|
|||||||
- **Attendee extras** — role (required / optional / organizer) + the user's own
|
- **Attendee extras** — role (required / optional / organizer) + the user's own
|
||||||
`SELF_ATTENDEE_STATUS`
|
`SELF_ATTENDEE_STATUS`
|
||||||
- **Timezone** (`EVENT_TIMEZONE`) — shown only when it differs from the device zone
|
- **Timezone** (`EVENT_TIMEZONE`) — shown only when it differs from the device zone
|
||||||
- **URL** — tappable link card
|
- **URL** — ~~tappable link card~~ **cut**: `CalendarContract` exposes no
|
||||||
|
`Events.URL` column (only `CUSTOM_APP_URI`, an originating-app deep-link).
|
||||||
|
URLs are instead surfaced by linkifying the description text
|
||||||
- **Access level / class** (private / confidential) — small chip (optional, trivial)
|
- **Access level / class** (private / confidential) — small chip (optional, trivial)
|
||||||
|
|
||||||
|
All of the above shipped in v0.6.0 (2026-06-11).
|
||||||
|
|
||||||
Deliberately out of v0.6:
|
Deliberately out of v0.6:
|
||||||
- Recurrence exception / modified-occurrence badges — `Instances` already
|
- Recurrence exception / modified-occurrence badges — `Instances` already
|
||||||
resolves correct per-occurrence times for display; this only matters for
|
resolves correct per-occurrence times for display; this only matters for
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
# Calendula — Current State
|
# Calendula — Current State
|
||||||
|
|
||||||
*Last updated: 2026-06-10*
|
*Last updated: 2026-06-11*
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
**Milestone:** v0.5 — Calendar filter (M3) + Settings (M4) (complete)
|
**Milestone:** v0.6 — Full event read (complete)
|
||||||
**Phase:** All V1 screens done. Jump-to-date (date-picker half of M2) cut from
|
**Phase:** All V1 screens done and the read model is now complete — the detail
|
||||||
scope. Next up is v0.6 — full event read (surface every readable
|
view surfaces every readable `CalendarContract` field. Next up is a UI
|
||||||
`CalendarContract` field) — then a polish/QA pass before v1.0
|
polish/QA pass before v1.0
|
||||||
|
|
||||||
## Progress
|
## Progress
|
||||||
|
|
||||||
@@ -23,12 +23,12 @@ scope. Next up is v0.6 — full event read (surface every readable
|
|||||||
- [x] Filter sheet (M3) — per-calendar visibility, grouped by account, persisted, applied centrally in the repository
|
- [x] Filter sheet (M3) — per-calendar visibility, grouped by account, persisted, applied centrally in the repository
|
||||||
- [x] Settings (M4) — appearance (theme, dynamic colour, week start), language (per-app locales), about
|
- [x] Settings (M4) — appearance (theme, dynamic colour, week start), language (per-app locales), about
|
||||||
- [~] Jump-to-date (M2) — **cut from scope**; "Today" half shipped in v0.5, date-picker dropped
|
- [~] Jump-to-date (M2) — **cut from scope**; "Today" half shipped in v0.5, date-picker dropped
|
||||||
|
- [x] Full event read (v0.6) — reminders, status, availability, access level,
|
||||||
|
attendee role + self-response, foreign timezone, and linkified description
|
||||||
|
URLs in the detail view; new domain enums + mapper unit tests. (A dedicated
|
||||||
|
URL field was cut — no `CalendarContract` column backs it.)
|
||||||
|
|
||||||
## Next
|
## Next
|
||||||
|
|
||||||
1. v0.6 — full event read: reminders, status, availability, attendee role +
|
1. UI polish / QA pass across all views before v1.0
|
||||||
self-status, timezone (when it differs), URL, access level. Read the
|
2. F-Droid release of v1.0
|
||||||
`CalendarContract` columns we don't yet pull and show them in the detail
|
|
||||||
view. (Planned, not started — implement another day.)
|
|
||||||
2. UI polish / QA pass across all views before v1.0
|
|
||||||
3. F-Droid release of v1.0
|
|
||||||
|
|||||||
30
CHANGELOG.md
30
CHANGELOG.md
@@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.6.0] — 2026-06-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Full event read (v0.6): the detail screen now surfaces every readable
|
||||||
|
`CalendarContract` field that V1 had been dropping —
|
||||||
|
- **Reminders** — each configured lead time, humanised ("10 minutes before",
|
||||||
|
"1 day before", "At time of event"), read from `CalendarContract.Reminders`
|
||||||
|
- **Status** — Tentative / Cancelled chip under the title; a cancelled event
|
||||||
|
also strikes through its title (Confirmed shows no chip)
|
||||||
|
- **Availability** — a Free / Busy chip (`Events.AVAILABILITY`, the iCal
|
||||||
|
TRANSP field)
|
||||||
|
- **Access level** — a Private / Confidential chip when the event isn't public
|
||||||
|
- **Attendee role** — organizer / optional / resource badge under each
|
||||||
|
attendee, plus the device user's own response ("Your response: …") from
|
||||||
|
`Events.SELF_ATTENDEE_STATUS`
|
||||||
|
- **Time zone** — shown only for timed events pinned to a zone other than the
|
||||||
|
device's, so cross-zone events read unambiguously
|
||||||
|
- **Linked URLs** — http(s) links in the description are now tappable
|
||||||
|
- Domain model rounded out with `Reminder`, `EventStatus`, `Availability`,
|
||||||
|
`AccessLevel`, `AttendeeRelationship`, `AttendeeType`, and the attendee/self
|
||||||
|
status fields; mappers + unit tests cover every new column's integer codes
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- `versionName`/`versionCode` bumped to 0.6.0 / 6
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
- A dedicated event **URL** field was dropped from scope: `CalendarContract`
|
||||||
|
has no `Events.URL` column (only `CUSTOM_APP_URI`, an app deep-link), so URLs
|
||||||
|
are surfaced by linkifying the description instead
|
||||||
|
|
||||||
## [0.5.0] — 2026-06-10
|
## [0.5.0] — 2026-06-10
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ android {
|
|||||||
applicationId = "de.jeanlucmakiola.calendula"
|
applicationId = "de.jeanlucmakiola.calendula"
|
||||||
minSdk = 29
|
minSdk = 29
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 5
|
versionCode = 6
|
||||||
versionName = "0.5.0"
|
versionName = "0.6.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
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.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -62,13 +63,14 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
|
|
||||||
override fun eventDetail(eventId: Long): EventDetail? {
|
override fun eventDetail(eventId: Long): EventDetail? {
|
||||||
val attendees = queryAttendees(eventId)
|
val attendees = queryAttendees(eventId)
|
||||||
|
val reminders = queryReminders(eventId)
|
||||||
return resolver.query(
|
return resolver.query(
|
||||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
||||||
EventDetailProjection.COLUMNS,
|
EventDetailProjection.COLUMNS,
|
||||||
null, null, null,
|
null, null, null,
|
||||||
)?.use { c ->
|
)?.use { c ->
|
||||||
if (!c.moveToFirst()) null
|
if (!c.moveToFirst()) null
|
||||||
else CursorColumnReader(c).toEventDetailCore(attendees)
|
else CursorColumnReader(c).toEventDetailCore(attendees, reminders)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +100,14 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
null,
|
null,
|
||||||
)?.use { c -> c.mapAll { CursorColumnReader(c).toAttendee() } } ?: emptyList()
|
)?.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()
|
private fun toCalendarSource(c: Cursor): CalendarSource = CursorColumnReader(c).toCalendarSource()
|
||||||
|
|
||||||
/** Iterate every row and map; skips nothing. */
|
/** Iterate every row and map; skips nothing. */
|
||||||
|
|||||||
@@ -2,14 +2,24 @@ package de.jeanlucmakiola.calendula.data.calendar
|
|||||||
|
|
||||||
import android.provider.CalendarContract
|
import android.provider.CalendarContract
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||||
import de.jeanlucmakiola.calendula.domain.Attendee
|
import de.jeanlucmakiola.calendula.domain.Attendee
|
||||||
|
import de.jeanlucmakiola.calendula.domain.AttendeeRelationship
|
||||||
import de.jeanlucmakiola.calendula.domain.AttendeeStatus
|
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.EventDetail
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
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"
|
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)
|
val begin = getLong(EventDetailProjection.IDX_DTSTART)
|
||||||
|
|
||||||
if (begin < 0L) {
|
if (begin < 0L) {
|
||||||
@@ -54,12 +64,28 @@ internal fun ColumnReader.toEventDetailCore(attendees: List<Attendee>): EventDet
|
|||||||
location = getString(EventDetailProjection.IDX_LOCATION),
|
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(
|
return EventDetail(
|
||||||
instance = instance,
|
instance = instance,
|
||||||
description = getString(EventDetailProjection.IDX_DESCRIPTION),
|
description = getString(EventDetailProjection.IDX_DESCRIPTION),
|
||||||
organizer = getString(EventDetailProjection.IDX_ORGANIZER),
|
organizer = getString(EventDetailProjection.IDX_ORGANIZER),
|
||||||
attendees = attendees,
|
attendees = attendees,
|
||||||
rrule = getString(EventDetailProjection.IDX_RRULE),
|
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(),
|
name = getString(AttendeeProjection.IDX_NAME).orEmpty(),
|
||||||
email = getString(AttendeeProjection.IDX_EMAIL),
|
email = getString(AttendeeProjection.IDX_EMAIL),
|
||||||
status = mapAttendeeStatus(getInt(AttendeeProjection.IDX_STATUS)),
|
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) {
|
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
|
CalendarContract.Attendees.ATTENDEE_STATUS_INVITED -> AttendeeStatus.NeedsAction
|
||||||
else -> AttendeeStatus.Unknown
|
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.ALL_DAY,
|
||||||
CalendarContract.Events.EVENT_LOCATION,
|
CalendarContract.Events.EVENT_LOCATION,
|
||||||
CalendarContract.Events.CALENDAR_ID,
|
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
|
const val IDX_EVENT_ID = 0
|
||||||
@@ -74,6 +79,11 @@ internal object EventDetailProjection {
|
|||||||
const val IDX_ALL_DAY = 9
|
const val IDX_ALL_DAY = 9
|
||||||
const val IDX_LOCATION = 10
|
const val IDX_LOCATION = 10
|
||||||
const val IDX_CALENDAR_ID = 11
|
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 {
|
internal object AttendeeProjection {
|
||||||
@@ -81,11 +91,25 @@ internal object AttendeeProjection {
|
|||||||
CalendarContract.Attendees.ATTENDEE_NAME,
|
CalendarContract.Attendees.ATTENDEE_NAME,
|
||||||
CalendarContract.Attendees.ATTENDEE_EMAIL,
|
CalendarContract.Attendees.ATTENDEE_EMAIL,
|
||||||
CalendarContract.Attendees.ATTENDEE_STATUS,
|
CalendarContract.Attendees.ATTENDEE_STATUS,
|
||||||
|
CalendarContract.Attendees.ATTENDEE_RELATIONSHIP,
|
||||||
|
CalendarContract.Attendees.ATTENDEE_TYPE,
|
||||||
)
|
)
|
||||||
|
|
||||||
const val IDX_NAME = 0
|
const val IDX_NAME = 0
|
||||||
const val IDX_EMAIL = 1
|
const val IDX_EMAIL = 1
|
||||||
const val IDX_STATUS = 2
|
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 {
|
internal object Fallbacks {
|
||||||
|
|||||||
@@ -29,12 +29,34 @@ data class EventDetail(
|
|||||||
val organizer: String?,
|
val organizer: String?,
|
||||||
val attendees: List<Attendee>,
|
val attendees: List<Attendee>,
|
||||||
val rrule: String?,
|
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(
|
data class Attendee(
|
||||||
val name: String,
|
val name: String,
|
||||||
val email: String?,
|
val email: String?,
|
||||||
val status: AttendeeStatus,
|
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 {
|
enum class AttendeeStatus {
|
||||||
@@ -45,6 +67,48 @@ enum class AttendeeStatus {
|
|||||||
Unknown,
|
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 {
|
enum class FailureReason {
|
||||||
PermissionRevoked,
|
PermissionRevoked,
|
||||||
NoCalendarsConfigured,
|
NoCalendarsConfigured,
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import androidx.compose.foundation.layout.Arrangement
|
|||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
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.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
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.CalendarMonth
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.Edit
|
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.People
|
||||||
import androidx.compose.material.icons.filled.Place
|
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.Repeat
|
||||||
import androidx.compose.material.icons.filled.Schedule
|
import androidx.compose.material.icons.filled.Schedule
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@@ -46,25 +50,36 @@ import androidx.compose.material3.TopAppBarDefaults
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.LinkAnnotation
|
||||||
import androidx.compose.ui.text.SpanStyle
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.TextLinkStyles
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
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.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||||
import de.jeanlucmakiola.calendula.domain.Attendee
|
import de.jeanlucmakiola.calendula.domain.Attendee
|
||||||
|
import de.jeanlucmakiola.calendula.domain.AttendeeRelationship
|
||||||
import de.jeanlucmakiola.calendula.domain.AttendeeStatus
|
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.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.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
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),
|
.padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 40.dp),
|
||||||
) {
|
) {
|
||||||
// Title with a short accent line in the calendar colour underneath.
|
// Title with a short accent line in the calendar colour underneath.
|
||||||
|
// A cancelled event strikes through the title.
|
||||||
Text(
|
Text(
|
||||||
text = instance.title.ifBlank { stringResource(R.string.event_untitled) },
|
text = instance.title.ifBlank { stringResource(R.string.event_untitled) },
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
textDecoration = if (detail.status == EventStatus.Cancelled) {
|
||||||
|
TextDecoration.LineThrough
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(10.dp))
|
Spacer(Modifier.height(10.dp))
|
||||||
Box(
|
Box(
|
||||||
@@ -173,6 +194,11 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
|||||||
.background(accent, RoundedCornerShape(2.dp)),
|
.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))
|
Spacer(Modifier.height(20.dp))
|
||||||
|
|
||||||
// Every piece of info shares one card design: a tonal container with a
|
// 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
|
// Calendar — icon tinted in the calendar colour conveys identity, so no
|
||||||
// separate colour dot is needed.
|
// separate colour dot is needed.
|
||||||
Spacer(Modifier.height(gap))
|
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 ->
|
detail.description?.takeIf { it.isNotBlank() }?.let { description ->
|
||||||
Spacer(Modifier.height(gap))
|
Spacer(Modifier.height(gap))
|
||||||
DetailCard(
|
DetailCard(
|
||||||
icon = Icons.AutoMirrored.Filled.Notes,
|
icon = Icons.AutoMirrored.Filled.Notes,
|
||||||
iconContentDescription = stringResource(R.string.event_detail_description),
|
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 ->
|
detail.attendees.takeIf { it.isNotEmpty() }?.let { attendees ->
|
||||||
Spacer(Modifier.height(gap))
|
Spacer(Modifier.height(gap))
|
||||||
DetailCard(
|
DetailCard(
|
||||||
icon = Icons.Default.People,
|
icon = Icons.Default.People,
|
||||||
iconContentDescription = stringResource(R.string.event_detail_attendees),
|
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) }
|
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.
|
// Recurrence (conditional) — humanised from the RRULE.
|
||||||
detail.rrule?.takeIf { it.isNotBlank() }?.let { rrule ->
|
detail.rrule?.takeIf { it.isNotBlank() }?.let { rrule ->
|
||||||
Spacer(Modifier.height(gap))
|
Spacer(Modifier.height(gap))
|
||||||
@@ -304,10 +377,20 @@ private fun AttendeeRow(attendee: Attendee) {
|
|||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
text = attendee.name.ifBlank { attendee.email.orEmpty() },
|
text = attendee.name.ifBlank { attendee.email.orEmpty() },
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
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(
|
||||||
text = stringResource(attendeeStatusLabel(attendee.status)),
|
text = stringResource(attendeeStatusLabel(attendee.status)),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
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
|
@Composable
|
||||||
private fun EventDetailLoading(modifier: Modifier = Modifier) {
|
private fun EventDetailLoading(modifier: Modifier = Modifier) {
|
||||||
Column(modifier = modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp)) {
|
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
|
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.
|
* 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".
|
* "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_needs_action">Keine Antwort</string>
|
||||||
<string name="event_attendee_unknown">—</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 -->
|
<!-- Geteilte Event-Strings -->
|
||||||
<string name="event_untitled">(Ohne Titel)</string>
|
<string name="event_untitled">(Ohne Titel)</string>
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,38 @@
|
|||||||
<string name="event_attendee_needs_action">No response</string>
|
<string name="event_attendee_needs_action">No response</string>
|
||||||
<string name="event_attendee_unknown">—</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 -->
|
<!-- Shared event strings -->
|
||||||
<string name="event_untitled">(No title)</string>
|
<string name="event_untitled">(No title)</string>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
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.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
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
class EventDetailMapperTest {
|
class EventDetailMapperTest {
|
||||||
@@ -19,6 +26,11 @@ class EventDetailMapperTest {
|
|||||||
allDay: Int = 0,
|
allDay: Int = 0,
|
||||||
location: String? = "Berlin",
|
location: String? = "Berlin",
|
||||||
calendarId: Long = 7L,
|
calendarId: Long = 7L,
|
||||||
|
status: Any? = null,
|
||||||
|
availability: Any? = null,
|
||||||
|
accessLevel: Any? = null,
|
||||||
|
timezone: String? = null,
|
||||||
|
selfStatus: Any? = null,
|
||||||
): MapColumnReader = MapColumnReader(
|
): MapColumnReader = MapColumnReader(
|
||||||
EventDetailProjection.IDX_EVENT_ID to eventId,
|
EventDetailProjection.IDX_EVENT_ID to eventId,
|
||||||
EventDetailProjection.IDX_TITLE to title,
|
EventDetailProjection.IDX_TITLE to title,
|
||||||
@@ -32,18 +44,42 @@ class EventDetailMapperTest {
|
|||||||
EventDetailProjection.IDX_ALL_DAY to allDay,
|
EventDetailProjection.IDX_ALL_DAY to allDay,
|
||||||
EventDetailProjection.IDX_LOCATION to location,
|
EventDetailProjection.IDX_LOCATION to location,
|
||||||
EventDetailProjection.IDX_CALENDAR_ID to calendarId,
|
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(
|
MapColumnReader(
|
||||||
AttendeeProjection.IDX_NAME to name,
|
AttendeeProjection.IDX_NAME to name,
|
||||||
AttendeeProjection.IDX_EMAIL to email,
|
AttendeeProjection.IDX_EMAIL to email,
|
||||||
AttendeeProjection.IDX_STATUS to status,
|
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
|
@Test
|
||||||
fun `happy path detail maps all fields and embeds matching EventInstance`() {
|
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).isNotNull()
|
||||||
assertThat(detail!!.description).isEqualTo("Body")
|
assertThat(detail!!.description).isEqualTo("Body")
|
||||||
assertThat(detail.organizer).isEqualTo("x@y")
|
assertThat(detail.organizer).isEqualTo("x@y")
|
||||||
@@ -55,21 +91,19 @@ class EventDetailMapperTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `event color falls back to calendar color when null`() {
|
fun `event color falls back to calendar color when null`() {
|
||||||
val detail = detailReader(eventColor = null, calendarColor = 0xFF112233.toInt())
|
val detail = detailReader(eventColor = null, calendarColor = 0xFF112233.toInt())
|
||||||
.toEventDetailCore(attendees = emptyList())
|
.toDetail()
|
||||||
assertThat(detail!!.instance.color).isEqualTo(0xFF112233.toInt())
|
assertThat(detail!!.instance.color).isEqualTo(0xFF112233.toInt())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `dtend before dtstart drops detail`() {
|
fun `dtend before dtstart drops detail`() {
|
||||||
val detail = detailReader(dtstart = 2000L, dtend = 1000L)
|
val detail = detailReader(dtstart = 2000L, dtend = 1000L).toDetail()
|
||||||
.toEventDetailCore(attendees = emptyList())
|
|
||||||
assertThat(detail).isNull()
|
assertThat(detail).isNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `rrule passes through when present`() {
|
fun `rrule passes through when present`() {
|
||||||
val detail = detailReader(rrule = "FREQ=WEEKLY;BYDAY=MO")
|
val detail = detailReader(rrule = "FREQ=WEEKLY;BYDAY=MO").toDetail()
|
||||||
.toEventDetailCore(attendees = emptyList())
|
|
||||||
assertThat(detail!!.rrule).isEqualTo("FREQ=WEEKLY;BYDAY=MO")
|
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", null, 1).toAttendee().email).isNull()
|
||||||
assertThat(attendeeReader("A", "x@y", 1).toAttendee().email).isEqualTo("x@y")
|
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