diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 7ee62b5..1f5e269 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -9,8 +9,8 @@ | v0.3 | Month + Week + Day views, view switcher | complete | | v0.4 | Event Detail (S4) + humanized recurrence | complete | | v0.5 | Calendar filter (M3) + Settings (M4) | complete | -| v0.6 | Full event read — surface every readable field | pending | -| v1.0 | Polish pass, F-Droid release | pending | +| v0.6 | Full event read — surface every readable field | complete | +| v1.0 | First public release — polish pass, F-Droid | complete | Delivery ran ahead of the original table: Day view (S3) shipped in v0.3 and Event Detail (S4) in v0.4, so the Filter/Settings milestone became v0.5. @@ -30,9 +30,13 @@ columns we don't yet read/display: - **Attendee extras** — role (required / optional / organizer) + the user's own `SELF_ATTENDEE_STATUS` - **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) +All of the above shipped in v0.6.0 (2026-06-11). + Deliberately out of v0.6: - Recurrence exception / modified-occurrence badges — `Instances` already resolves correct per-occurrence times for display; this only matters for @@ -40,10 +44,14 @@ Deliberately out of v0.6: - `CATEGORIES`, `ATTACH` — not reliably exposed by `CalendarContract` (provider limitation, not our choice) -## v1.0 — First Public Release +## v1.0 — First Public Release — shipped 2026-06-11 -All V1 features shipped, polished, on F-Droid. Read-only calendar. -Remaining before v1.0: a UI polish/QA pass. +All V1 features shipped, polished, on F-Droid. Read-only calendar. Cut directly +after v0.6 (full event read) plus the onboarding-screen polish pass. + +### Polish backlog (pre-1.0) +- ~~Redesign the initial grant-access (permission) screen~~ — **done** + (Material 3 Expressive onboarding, shipped in v0.6.0 / v1.0.0) ## v2.0 — Write Support diff --git a/.planning/STATE.md b/.planning/STATE.md index 07f1a12..5673f74 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -1,13 +1,13 @@ # Calendula — Current State -*Last updated: 2026-06-10* +*Last updated: 2026-06-11* ## Status -**Milestone:** v0.5 — Calendar filter (M3) + Settings (M4) (complete) -**Phase:** All V1 screens done. Jump-to-date (date-picker half of M2) cut from -scope. Next up is v0.6 — full event read (surface every readable -`CalendarContract` field) — then a polish/QA pass before v1.0 +**Milestone:** v1.0.0 — First public release (shipped 2026-06-11) +**Phase:** V1 is complete and released. All screens done, the read model +surfaces every readable `CalendarContract` field, and the onboarding screen +got its Material 3 Expressive polish pass. Next horizon is v2.0 (write support) ## 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] 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 +- [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 -1. v0.6 — full event read: reminders, status, availability, attendee role + - self-status, timezone (when it differs), URL, access level. Read the - `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 +1. v1.0.0 released — monitor the F-Droid build/publish +2. Plan v2.0 (write support: create / edit / delete, quick-add, conflict UX) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2d1b42..25e0f61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,66 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.0] — 2026-06-11 + +First public release. Calendula is a read-only, Material 3 Expressive calendar +that lives entirely on top of Android's `CalendarContract` — every calendar +synced to the device (CalDAV via DAVx5, Google, local, WebCal, …) shows up +automatically, with zero telemetry and no internet permission. + +### Highlights (accumulated across v0.1 → v0.6) +- Month, week, and day views with a view switcher, swipe navigation, and + Loading / Failure / Success states on every screen +- Full-screen event detail surfacing every readable `CalendarContract` field — + times, recurrence (humanised), location, description (with tappable links), + attendees + roles + your own response, reminders, status, availability, + access level, and foreign time zones +- Per-calendar visibility filter (grouped by account, persisted) and a Settings + screen (theme, Material You dynamic colour, week start, app language) +- Material 3 Expressive first-run onboarding for calendar access +- German + English localization throughout + +### Changed +- `versionName`/`versionCode` bumped to 1.0.0 / 7 + +## [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" pill pinned top-right of the title when the + event doesn't block your time (`Events.AVAILABILITY`, the iCal TRANSP + field); the default "Busy" is left implicit to avoid noise on every event + - **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 +- Redesigned the first-run grant-access screen — the onboarding a new user + sees. Material 3 Expressive layout: branded launcher-mark hero, an app-name + eyebrow, a benefit-led headline, three trust rows (on-device, every calendar, + no tracking) with tonal icon chips, a full-width filled CTA with a trailing + arrow, and a "Read-only · no internet permission" footnote (the app declares + only `READ_CALENDAR`). The denied/recovery state shares the same shell with a + lock-badged hero and Open-settings / Try-again actions +- `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 ### Added diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 75cd4de..328c309 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -23,8 +23,8 @@ android { applicationId = "de.jeanlucmakiola.calendula" minSdk = 29 targetSdk = 36 - versionCode = 5 - versionName = "0.5.0" + versionCode = 7 + versionName = "1.0.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt index 5da24dd..0b69473 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt @@ -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 = 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. */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapper.kt index 90c8ffa..af44024 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapper.kt @@ -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): EventDetail? { +internal fun ColumnReader.toEventDetailCore( + attendees: List, + reminders: List, +): EventDetail? { val begin = getLong(EventDetailProjection.IDX_DTSTART) if (begin < 0L) { @@ -54,12 +64,28 @@ internal fun ColumnReader.toEventDetailCore(attendees: List): 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 +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt index 5a2a6a5..65943d1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt @@ -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 = arrayOf( + CalendarContract.Reminders.MINUTES, + CalendarContract.Reminders.METHOD, + ) + + const val IDX_MINUTES = 0 + const val IDX_METHOD = 1 } internal object Fallbacks { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt index 5ccb921..fdb135c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/Models.kt @@ -29,12 +29,34 @@ data class EventDetail( val organizer: String?, val attendees: List, val rrule: String?, + /** Reminders (VALARM) configured on the event, ascending lead time. */ + val reminders: List = 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, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt index ef6fb84..6d5fb6c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt @@ -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 @@ -159,12 +174,30 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi .verticalScroll(rememberScrollState()) .padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 40.dp), ) { - // Title with a short accent line in the calendar colour underneath. - Text( - text = instance.title.ifBlank { stringResource(R.string.event_untitled) }, - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.SemiBold, - ) + // Title row: title on the left, a "Free" pill pinned top-right when the + // event doesn't block your time. Busy is the default for nearly every + // event, so it's left implicit — only Free is worth surfacing. A + // cancelled event strikes through its title. + Row(verticalAlignment = Alignment.Top) { + 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 + }, + modifier = Modifier.weight(1f), + ) + if (detail.availability == Availability.Free) { + Spacer(Modifier.width(12.dp)) + InfoChip( + text = stringResource(R.string.event_availability_free), + modifier = Modifier.padding(top = 6.dp), + ) + } + } Spacer(Modifier.height(10.dp)) Box( modifier = Modifier @@ -173,6 +206,16 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi .background(accent, RoundedCornerShape(2.dp)), ) + // Status / access chips — shown only when noteworthy (Confirmed status + // and Default/Public access are the silent norm). + val hasStatusChips = detail.status != EventStatus.Confirmed || + detail.accessLevel == AccessLevel.Private || + detail.accessLevel == AccessLevel.Confidential + if (hasStatusChips) { + Spacer(Modifier.height(16.dp)) + StatusChips(detail.status, detail.accessLevel) + } + Spacer(Modifier.height(20.dp)) // Every piece of info shares one card design: a tonal container with a @@ -194,6 +237,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 +283,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 +394,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 +416,54 @@ private fun AttendeeRow(attendee: Attendee) { } } +/** Status / access pills shown directly under the title accent. */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun StatusChips(status: EventStatus, 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 + } + + 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, + modifier: Modifier = Modifier, + container: Color = MaterialTheme.colorScheme.surfaceContainerHighest, + content: Color = MaterialTheme.colorScheme.onSurfaceVariant, +) { + Surface(color = container, shape = RoundedCornerShape(8.dp), modifier = modifier) { + 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 +509,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". diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/permission/PermissionScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/permission/PermissionScreen.kt index 7673484..d53b008 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/permission/PermissionScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/permission/PermissionScreen.kt @@ -6,28 +6,65 @@ import android.net.Uri import android.provider.Settings import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background 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.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.filled.CalendarMonth +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.compose.foundation.Image import de.jeanlucmakiola.calendula.R +// MD3 8dp spacing scale, scoped to this screen. +private object Space { + val xs = 8.dp + val sm = 16.dp + val md = 24.dp + val lg = 32.dp + val xl = 48.dp +} + @Composable fun PermissionScreen( onGranted: () -> Unit, @@ -69,24 +106,68 @@ private fun RationaleContent( onRequest: () -> Unit, modifier: Modifier = Modifier, ) { - Column( - modifier = modifier.fillMaxSize().padding(24.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, + PermissionScaffold( + modifier = modifier, + hero = { BrandHero(denied = false) }, + actions = { + Button( + onClick = onRequest, + modifier = Modifier.fillMaxWidth().height(56.dp), + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + ) { + Text( + text = stringResource(R.string.permission_request_button), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(Modifier.width(Space.xs)) + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + } + PrivacyFootnote() + }, ) { + Text( + text = stringResource(R.string.app_name).uppercase(), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + letterSpacing = 2.sp, + ) + Spacer(Modifier.height(Space.xs)) Text( text = stringResource(R.string.permission_rationale_title), style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center, ) - Spacer(Modifier.height(16.dp)) + Spacer(Modifier.height(12.dp)) Text( text = stringResource(R.string.permission_rationale_body), style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + + Spacer(Modifier.height(Space.xl)) + + BenefitRow( + icon = Icons.Filled.Lock, + title = stringResource(R.string.permission_benefit_private_title), + body = stringResource(R.string.permission_benefit_private_body), + ) + Spacer(Modifier.height(Space.sm)) + BenefitRow( + icon = Icons.Filled.CalendarMonth, + title = stringResource(R.string.permission_benefit_sync_title), + body = stringResource(R.string.permission_benefit_sync_body), + ) + Spacer(Modifier.height(Space.sm)) + BenefitRow( + icon = Icons.Filled.VisibilityOff, + title = stringResource(R.string.permission_benefit_privacy_title), + body = stringResource(R.string.permission_benefit_privacy_body), ) - Spacer(Modifier.height(32.dp)) - Button(onClick = onRequest) { - Text(stringResource(R.string.permission_request_button)) - } } } @@ -96,35 +177,182 @@ private fun DeniedContent( modifier: Modifier = Modifier, ) { val context = LocalContext.current - Column( - modifier = modifier.fillMaxSize().padding(24.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, + PermissionScaffold( + modifier = modifier, + hero = { BrandHero(denied = true) }, + actions = { + Button( + onClick = { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", context.packageName, null) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + }, + modifier = Modifier.fillMaxWidth().height(56.dp), + ) { + Text( + text = stringResource(R.string.permission_open_settings_button), + style = MaterialTheme.typography.titleMedium, + ) + } + TextButton( + onClick = onRetry, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.permission_retry_button)) + } + }, ) { Text( text = stringResource(R.string.permission_denied_title), style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center, ) - Spacer(Modifier.height(16.dp)) + Spacer(Modifier.height(12.dp)) Text( text = stringResource(R.string.permission_denied_body), style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, ) - Spacer(Modifier.height(32.dp)) - Button(onClick = onRetry) { - Text(stringResource(R.string.permission_retry_button)) - } - Spacer(Modifier.height(12.dp)) - OutlinedButton( - onClick = { - val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { - data = Uri.fromParts("package", context.packageName, null) - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - context.startActivity(intent) - }, + } +} + +/** + * Shared onboarding shell: a scrollable, centred hero + body with the call(s) to + * action pinned to the bottom (clear of the navigation bar). The content slot is + * centred horizontally; benefit rows fill the width so their own content + * left-aligns. + */ +@Composable +private fun PermissionScaffold( + hero: @Composable () -> Unit, + actions: @Composable ColumnScope.() -> Unit, + modifier: Modifier = Modifier, + body: @Composable ColumnScope.() -> Unit, +) { + Scaffold( + modifier = modifier, + containerColor = MaterialTheme.colorScheme.surface, + bottomBar = { + Column( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(horizontal = Space.md, vertical = Space.sm), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + content = actions, + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = Space.md), + horizontalAlignment = Alignment.CenterHorizontally, ) { - Text(stringResource(R.string.permission_open_settings_button)) + Spacer(Modifier.height(Space.xl)) + hero() + Spacer(Modifier.height(Space.lg)) + body() + Spacer(Modifier.height(Space.md)) } } } + +/** The app's adaptive launcher mark, reconstructed as a large branded squircle. */ +@Composable +private fun BrandHero(denied: Boolean) { + Box(contentAlignment = Alignment.Center) { + Box( + modifier = Modifier + .size(128.dp) + .clip(RoundedCornerShape(34.dp)) + .background(colorResource(R.color.ic_launcher_background)), + ) { + Image( + painter = painterResource(R.drawable.ic_launcher_foreground), + contentDescription = stringResource(R.string.app_name), + modifier = Modifier.fillMaxSize(), + ) + } + if (denied) { + // A small lock badge sits over the corner to signal "blocked". + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .offset(x = 10.dp, y = 10.dp) + .size(44.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.errorContainer), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Filled.Lock, + contentDescription = null, + tint = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.size(24.dp), + ) + } + } + } +} + +/** One trust point: a tonal icon chip on the left, title + supporting text right. */ +@Composable +private fun BenefitRow(icon: ImageVector, title: String, body: String) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(44.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.secondaryContainer), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.size(22.dp), + ) + } + Spacer(Modifier.width(Space.sm)) + Column(modifier = Modifier.weight(1f)) { + Text(text = title, style = MaterialTheme.typography.titleMedium) + Text( + text = body, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun PrivacyFootnote() { + Row( + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Filled.Lock, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(14.dp), + ) + Spacer(Modifier.width(6.dp)) + Text( + text = stringResource(R.string.permission_privacy_footnote), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index bd85f5f..6603370 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -12,13 +12,20 @@ Kalender konnte nicht gelesen werden. - Kalender-Zugriff - Calendula liest nur deinen Gerätekalender — keine Daten verlassen das Gerät. - Weiter + Alle Termine, schön im Blick + Calendula braucht Lesezugriff auf deinen Kalender, um deine Termine zu zeigen. Mehr verlangt die App nie. + Kalender-Zugriff erlauben Kalender-Zugriff abgelehnt Ohne Kalender-Zugriff kann Calendula keine Termine anzeigen. Du kannst den Zugriff in den System-Einstellungen wieder erlauben. System-Einstellungen öffnen Erneut versuchen + Bleibt auf deinem Gerät + Deine Kalender werden lokal gelesen und verlassen das Telefon nie. + Alle Kalender vereint + Google, CalDAV, lokal — alles, was synchronisiert ist, erscheint automatisch. + Kein Tracking, niemals + Keine Telemetrie, keine Analyse, keine Werbung. + Nur Lesezugriff · keine Internet-Berechtigung Vorheriger Monat @@ -66,6 +73,37 @@ Keine Antwort + + Erinnerungen + Zeitzone + Vorläufig + Abgesagt + Frei + Privat + Vertraulich + Organisator + Optional + Ressource + Deine Antwort: %1$s + Zur Startzeit + Standarderinnerung + + %d Minute vorher + %d Minuten vorher + + + %d Stunde vorher + %d Stunden vorher + + + %d Tag vorher + %d Tage vorher + + + %d Woche vorher + %d Wochen vorher + + (Ohne Titel) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 74cc5ec..f632463 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -13,13 +13,20 @@ Could not read the calendar. - Calendar access - Calendula reads only your device calendar — no data leaves your device. - Continue + See all your events, beautifully + Calendula needs to read your calendar to show your events. That\'s the only thing it ever asks for. + Grant calendar access Calendar access denied Calendula cannot show events without calendar access. You can grant it again in the system settings. Open system settings Try again + Stays on your device + Your calendars are read locally and never leave the phone. + All your calendars, together + Google, CalDAV, local — anything synced to the device just appears. + No tracking, ever + Zero telemetry, zero analytics, no ads. + Read-only · no internet permission Previous month @@ -67,6 +74,37 @@ No response + + Reminders + Time zone + Tentative + Cancelled + Free + Private + Confidential + Organizer + Optional + Resource + Your response: %1$s + At time of event + Default reminder + + %d minute before + %d minutes before + + + %d hour before + %d hours before + + + %d day before + %d days before + + + %d week before + %d weeks before + + (No title) diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapperTest.kt index 9100767..b22b6cb 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapperTest.kt @@ -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 = emptyList(), + reminders: List = 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)) + } }