7 Commits

Author SHA1 Message Date
e290c92d78 docs: fold onboarding redesign into 0.6.0 changelog
Some checks failed
Build and Release to F-Droid / ci (push) Has been cancelled
Build and Release to F-Droid / build-and-deploy (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 09:23:42 +02:00
9c4ebbc65a feat(permission): redesign first-run grant-access screen (M3 Expressive)
The onboarding screen is the first thing a new user sees; it was a bare
centred title + body + button. Rebuild it as a proper Material 3 Expressive
welcome:

- Branded hero reconstructing the launcher mark (slate squircle + foreground
  vector); the denied state adds a lock badge over the corner
- App-name eyebrow, a benefit-led headline, and three trust rows (stays on
  device / every calendar together / no tracking) with tonal icon chips
- Full-width filled CTA with a trailing arrow, pinned in a Scaffold bottom bar
  clear of the navigation bar; scrollable body for short screens
- "Read-only · no internet permission" footnote — accurate: the app declares
  only READ_CALENDAR
- Denied/recovery state reuses the same shell with Open-settings (primary) and
  Try-again (text) actions
- 8dp spacing scale, edge-to-edge insets handled via Scaffold

Built with the newly installed material-3 skill's token/component guidance.
Resolves the pre-1.0 polish backlog item.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 09:17:54 +02:00
c0d413ba11 docs: backlog the initial grant-access screen redesign (pre-1.0 polish)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 09:08:21 +02:00
dca0245a42 refactor(detail): show availability only when Free, pin it by the title
The Busy availability value is the default for nearly every event, so a
"Busy" chip on every detail screen was noise. Show the pill only for Free
(the noteworthy case) and move it to the top-right of the title row instead
of the under-title chip strip. That strip now carries only status/access and
hides entirely when there's nothing noteworthy. Drops the now-unused
event_availability_busy string from both locales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 09:08:21 +02:00
024512959f feat(detail): full event read — surface every readable field (v0.6.0)
Round out the read-only model so the detail view shows everything
CalendarContract actually stores, ahead of write support.

Data layer:
- New domain types: Reminder, EventStatus, Availability, AccessLevel,
  AttendeeRelationship, AttendeeType; EventDetail gains reminders, status,
  availability, accessLevel, eventTimezone, selfStatus and Attendee gains
  relationship + type (all defaulted so existing callers compile)
- EventDetailProjection reads STATUS / AVAILABILITY / ACCESS_LEVEL /
  EVENT_TIMEZONE / SELF_ATTENDEE_STATUS; AttendeeProjection reads
  RELATIONSHIP + TYPE; new ReminderProjection queries CalendarContract.Reminders
- Mappers translate each provider integer code, guarding STATUS's null-vs-0
  ambiguity (0 == TENTATIVE) so an absent status reads as Confirmed
- Mapper unit tests cover every new column's codes

Detail UI:
- Status / availability / access chips under the title; cancelled also strikes
  the title through
- Reminders card with humanised lead times (plurals, DE + EN)
- Foreign-timezone card, shown only for timed events in a non-device zone
- Attendee role badges + the user's own "Your response: …" line
- http(s) URLs in the description are now tappable

URL field cut: CalendarContract exposes no Events.URL column (only the
CUSTOM_APP_URI app deep-link), so URLs are surfaced by linkifying the
description instead. Recorded in ROADMAP/CHANGELOG.

Version bumped to 0.6.0 / 6.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 08:56:40 +02:00
e78da3d7c1 docs: add v0.6 "full event read" milestone before v1.0
All checks were successful
CI / ci (push) Successful in 12m21s
Plan to surface every readable CalendarContract field (reminders, status,
availability, attendee role + self-status, timezone, URL, access level) in
the detail view before write support. Recurrence-override badges and
CATEGORIES/ATTACH stay out (the former folds into v2, the latter is a
provider limitation). Noted only — implementation comes later.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 23:35:02 +02:00
2cb8b59fb7 docs: cut jump-to-date (M2) from V1 scope
The date-picker half of M2 is dropped entirely; the "Today" half already
shipped in v0.5. V1 is now feature-complete and only a polish/QA pass
remains before v1.0.

Updated the living planning docs (ROADMAP, STATE, REQUIREMENTS) and the
design spec; corrected the v0.5.0 CHANGELOG note that promised M2 would
return in v1.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 23:27:21 +02:00
15 changed files with 962 additions and 76 deletions

View File

@@ -17,7 +17,7 @@ See full design spec: `docs/superpowers/specs/2026-06-08-calendar-app-design.md`
- [ ] Day view (S3) - [ ] Day view (S3)
- [ ] Event Detail Sheet (S4) - [ ] Event Detail Sheet (S4)
- [ ] Multi-Calendar Filter (M3) - [ ] Multi-Calendar Filter (M3)
- [ ] Today button + Jump-to-Date (M2) - [x] Today button (M2) — shipped v0.5; Jump-to-Date **cut from scope**
- [ ] View-Switcher (M1) - [ ] View-Switcher (M1)
- [ ] Settings screen (M4) - [ ] Settings screen (M4)
- [ ] Empty / no-permission / no-calendars states - [ ] Empty / no-permission / no-calendars states

View File

@@ -9,16 +9,49 @@
| 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 |
| v1.0 | Polish + jump-to-date (M2), F-Droid release | pending | | v0.6 | Full event read — surface every readable field | complete |
| 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
Event Detail (S4) in v0.4, so the Filter/Settings milestone became v0.5. Event Detail (S4) in v0.4, so the Filter/Settings milestone became v0.5.
Jump-to-date (M2) was deferred out of v0.5 and folds into the v1.0 polish pass.
Jump-to-date (the date-picker half of M2) was **cut from scope** and will not
ship. The "Today" half of M2 already shipped in v0.5 (drawer entry).
## v0.6 — Full event read
Round out the read-only model so a detail view shows everything the system
actually stores, before write support starts. Scope = `CalendarContract`
columns we don't yet read/display:
- **Reminders** (`VALARM`) — read `CalendarContract.Reminders`, list lead times
- **Status** — Confirmed / Tentative / Cancelled (cancelled shown struck-through)
- **Availability** (`TRANSP`) — Free / Busy chip
- **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~~ **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
editing, so it folds into v2
- `CATEGORIES`, `ATTACH` — not reliably exposed by `CalendarContract`
(provider limitation, not our choice)
## v1.0 — First Public Release ## v1.0 — First Public Release
All V1 features shipped, polished, on F-Droid. Read-only calendar. All V1 features shipped, polished, on F-Droid. Read-only calendar.
Remaining before v1.0: jump-to-date (M2) and a UI polish/QA pass. Remaining before v1.0: a UI polish/QA pass.
### Polish backlog (pre-1.0)
- ~~Redesign the initial grant-access (permission) screen~~ — **done**
(Material 3 Expressive onboarding, shipped on the v0.6.0 branch)
## v2.0 — Write Support ## v2.0 — Write Support

View File

@@ -1,12 +1,13 @@
# Calendula — Current State # Calendula — Current State
*Last updated: 2026-06-10* *Last updated: 2026-06-11*
## Status ## Status
**Milestone:** v0.5Calendar filter (M3) + Settings (M4) (complete) **Milestone:** v0.6Full event read (complete)
**Phase:** All V1 screens and cross-cutting wiring done except jump-to-date **Phase:** All V1 screens done and the read model is now complete — the detail
(M2), which is deferred to the v1.0 polish pass view surfaces every readable `CalendarContract` field. Next up is a UI
polish/QA pass before v1.0
## Progress ## Progress
@@ -21,10 +22,13 @@
- [x] Event-detail screen (S4) — full-screen, humanized recurrence - [x] Event-detail screen (S4) — full-screen, humanized recurrence
- [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) — drawer entry still stubbed (deferred to v1.0) - [~] 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. Jump-to-date (M2) — date picker from the drawer, reachable on every view 1. UI polish / QA pass across all views before v1.0
2. UI polish / QA pass across all views before v1.0 2. F-Droid release of v1.0
3. F-Droid release of v1.0

View File

@@ -7,6 +7,44 @@ 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" 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 ## [0.5.0] — 2026-06-10
### Added ### Added
@@ -16,7 +54,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
separate from the system VISIBLE flag) and applied centrally in the separate from the system VISIBLE flag) and applied centrally in the
repository, so month/week/day re-filter live the moment a switch flips. repository, so month/week/day re-filter live the moment a switch flips.
The drawer was trimmed to just Today, the calendar filter, and Settings The drawer was trimmed to just Today, the calendar filter, and Settings
(the stubbed jump-to-date entry was removed; M2 returns in v1.0) (the stubbed jump-to-date entry was removed; jump-to-date was later cut
from scope entirely)
- Settings (M4): a full-screen destination with - Settings (M4): a full-screen destination with
- **Appearance** — theme (System / Light / Dark), Material You dynamic colour - **Appearance** — theme (System / Light / Dark), Material You dynamic colour
(auto-disabled below Android 12), week start (Automatic / Monday / Sunday) (auto-disabled below Android 12), week start (Automatic / Monday / Sunday)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
@@ -159,12 +174,30 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.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 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(
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
},
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)) Spacer(Modifier.height(10.dp))
Box( Box(
modifier = Modifier modifier = Modifier
@@ -173,6 +206,16 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
.background(accent, RoundedCornerShape(2.dp)), .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)) 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 +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 // 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 +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 -> 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 +394,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 +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 @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 +509,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".

View File

@@ -6,28 +6,65 @@ import android.net.Uri
import android.provider.Settings import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
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.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
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height 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.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.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.platform.LocalContext
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.foundation.Image
import de.jeanlucmakiola.calendula.R 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 @Composable
fun PermissionScreen( fun PermissionScreen(
onGranted: () -> Unit, onGranted: () -> Unit,
@@ -69,24 +106,68 @@ private fun RationaleContent(
onRequest: () -> Unit, onRequest: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Column( PermissionScaffold(
modifier = modifier.fillMaxSize().padding(24.dp), modifier = modifier,
verticalArrangement = Arrangement.Center, hero = { BrandHero(denied = false) },
horizontalAlignment = Alignment.CenterHorizontally, 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(
text = stringResource(R.string.permission_rationale_title), text = stringResource(R.string.permission_rationale_title),
style = MaterialTheme.typography.headlineMedium, style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
) )
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(12.dp))
Text( Text(
text = stringResource(R.string.permission_rationale_body), text = stringResource(R.string.permission_rationale_body),
style = MaterialTheme.typography.bodyLarge, 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,26 +177,11 @@ private fun DeniedContent(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current val context = LocalContext.current
Column( PermissionScaffold(
modifier = modifier.fillMaxSize().padding(24.dp), modifier = modifier,
verticalArrangement = Arrangement.Center, hero = { BrandHero(denied = true) },
horizontalAlignment = Alignment.CenterHorizontally, actions = {
) { Button(
Text(
text = stringResource(R.string.permission_denied_title),
style = MaterialTheme.typography.headlineMedium,
)
Spacer(Modifier.height(16.dp))
Text(
text = stringResource(R.string.permission_denied_body),
style = MaterialTheme.typography.bodyLarge,
)
Spacer(Modifier.height(32.dp))
Button(onClick = onRetry) {
Text(stringResource(R.string.permission_retry_button))
}
Spacer(Modifier.height(12.dp))
OutlinedButton(
onClick = { onClick = {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", context.packageName, null) data = Uri.fromParts("package", context.packageName, null)
@@ -123,8 +189,170 @@ private fun DeniedContent(
} }
context.startActivity(intent) context.startActivity(intent)
}, },
modifier = Modifier.fillMaxWidth().height(56.dp),
) { ) {
Text(stringResource(R.string.permission_open_settings_button)) 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(12.dp))
Text(
text = stringResource(R.string.permission_denied_body),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
/**
* 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,
) {
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,
)
}
}

View File

@@ -12,13 +12,20 @@
<string name="state_failure_provider">Kalender konnte nicht gelesen werden.</string> <string name="state_failure_provider">Kalender konnte nicht gelesen werden.</string>
<!-- Permission-Flow (F1) --> <!-- Permission-Flow (F1) -->
<string name="permission_rationale_title">Kalender-Zugriff</string> <string name="permission_rationale_title">Alle Termine, schön im Blick</string>
<string name="permission_rationale_body">Calendula liest nur deinen Gerätekalender — keine Daten verlassen das Gerät.</string> <string name="permission_rationale_body">Calendula braucht Lesezugriff auf deinen Kalender, um deine Termine zu zeigen. Mehr verlangt die App nie.</string>
<string name="permission_request_button">Weiter</string> <string name="permission_request_button">Kalender-Zugriff erlauben</string>
<string name="permission_denied_title">Kalender-Zugriff abgelehnt</string> <string name="permission_denied_title">Kalender-Zugriff abgelehnt</string>
<string name="permission_denied_body">Ohne Kalender-Zugriff kann Calendula keine Termine anzeigen. Du kannst den Zugriff in den System-Einstellungen wieder erlauben.</string> <string name="permission_denied_body">Ohne Kalender-Zugriff kann Calendula keine Termine anzeigen. Du kannst den Zugriff in den System-Einstellungen wieder erlauben.</string>
<string name="permission_open_settings_button">System-Einstellungen öffnen</string> <string name="permission_open_settings_button">System-Einstellungen öffnen</string>
<string name="permission_retry_button">Erneut versuchen</string> <string name="permission_retry_button">Erneut versuchen</string>
<string name="permission_benefit_private_title">Bleibt auf deinem Gerät</string>
<string name="permission_benefit_private_body">Deine Kalender werden lokal gelesen und verlassen das Telefon nie.</string>
<string name="permission_benefit_sync_title">Alle Kalender vereint</string>
<string name="permission_benefit_sync_body">Google, CalDAV, lokal — alles, was synchronisiert ist, erscheint automatisch.</string>
<string name="permission_benefit_privacy_title">Kein Tracking, niemals</string>
<string name="permission_benefit_privacy_body">Keine Telemetrie, keine Analyse, keine Werbung.</string>
<string name="permission_privacy_footnote">Nur Lesezugriff · keine Internet-Berechtigung</string>
<!-- Monatsansicht (S1) --> <!-- Monatsansicht (S1) -->
<string name="month_prev">Vorheriger Monat</string> <string name="month_prev">Vorheriger Monat</string>
@@ -66,6 +73,37 @@
<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_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>

View File

@@ -13,13 +13,20 @@
<string name="state_failure_provider">Could not read the calendar.</string> <string name="state_failure_provider">Could not read the calendar.</string>
<!-- Permission flow (F1) --> <!-- Permission flow (F1) -->
<string name="permission_rationale_title">Calendar access</string> <string name="permission_rationale_title">See all your events, beautifully</string>
<string name="permission_rationale_body">Calendula reads only your device calendar — no data leaves your device.</string> <string name="permission_rationale_body">Calendula needs to read your calendar to show your events. That\'s the only thing it ever asks for.</string>
<string name="permission_request_button">Continue</string> <string name="permission_request_button">Grant calendar access</string>
<string name="permission_denied_title">Calendar access denied</string> <string name="permission_denied_title">Calendar access denied</string>
<string name="permission_denied_body">Calendula cannot show events without calendar access. You can grant it again in the system settings.</string> <string name="permission_denied_body">Calendula cannot show events without calendar access. You can grant it again in the system settings.</string>
<string name="permission_open_settings_button">Open system settings</string> <string name="permission_open_settings_button">Open system settings</string>
<string name="permission_retry_button">Try again</string> <string name="permission_retry_button">Try again</string>
<string name="permission_benefit_private_title">Stays on your device</string>
<string name="permission_benefit_private_body">Your calendars are read locally and never leave the phone.</string>
<string name="permission_benefit_sync_title">All your calendars, together</string>
<string name="permission_benefit_sync_body">Google, CalDAV, local — anything synced to the device just appears.</string>
<string name="permission_benefit_privacy_title">No tracking, ever</string>
<string name="permission_benefit_privacy_body">Zero telemetry, zero analytics, no ads.</string>
<string name="permission_privacy_footnote">Read-only · no internet permission</string>
<!-- Month view (S1) --> <!-- Month view (S1) -->
<string name="month_prev">Previous month</string> <string name="month_prev">Previous month</string>
@@ -67,6 +74,37 @@
<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_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>

View File

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

View File

@@ -27,7 +27,7 @@ von Google Calendar gibt - speziell mit dem 2025er Expressive-Design.
- 3 Hauptansichten: Monat, Woche, Tag - 3 Hauptansichten: Monat, Woche, Tag
- Event-Detail-Sheet (read-only Detailansicht) - Event-Detail-Sheet (read-only Detailansicht)
- Multi-Kalender-Toggle (Sichtbarkeit pro Kalender) - Multi-Kalender-Toggle (Sichtbarkeit pro Kalender)
- Heute-Button + Jump-to-Date - Heute-Button (Jump-to-Date gestrichen, siehe Out-of-Scope)
- Settings-Screen (Theme, Dynamic Color, Wochenstart, Sprache) - Settings-Screen (Theme, Dynamic Color, Wochenstart, Sprache)
- Permission-Flow für `READ_CALENDAR` - Permission-Flow für `READ_CALENDAR`
- Empty-States und Error-Recovery - Empty-States und Error-Recovery
@@ -35,6 +35,7 @@ von Google Calendar gibt - speziell mit dem 2025er Expressive-Design.
- Tests + CI ab Tag 1 - Tests + CI ab Tag 1
### Out-of-Scope (V2+) ### Out-of-Scope (V2+)
- Jump-to-Date / Datum-Picker (aus V1-Scope gestrichen)
- Event-Create/Edit/Delete (V2) - Event-Create/Edit/Delete (V2)
- Home-Screen-Widget - Home-Screen-Widget
- Volltextsuche - Volltextsuche
@@ -202,9 +203,9 @@ fragt Repository nur für sichtbaren Range an - kein "alle Events der Welt".
- Immer erreichbar von allen Hauptansichten - Immer erreichbar von allen Hauptansichten
- State persistent (zuletzt aktive Ansicht) - State persistent (zuletzt aktive Ansicht)
**M2 - Heute / Springe-zu-Datum** **M2 - Heute**
- Schnell zurück zu "heute" - Schnell zurück zu "heute" (Drawer-Eintrag, ausgeliefert in v0.5)
- Springe zu beliebigem Datum via Datum-Picker - ~~Springe zu beliebigem Datum via Datum-Picker~~ — **gestrichen**, siehe Out-of-Scope
- Erreichbar von allen Hauptansichten - Erreichbar von allen Hauptansichten
**M3 - Kalender-Filter (Bottom-Sheet)** **M3 - Kalender-Filter (Bottom-Sheet)**