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)
- [ ] Event Detail Sheet (S4)
- [ ] 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)
- [ ] Settings screen (M4)
- [ ] Empty / no-permission / no-calendars states

View File

@@ -9,16 +9,49 @@
| 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 |
| 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
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
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

View File

@@ -1,12 +1,13 @@
# Calendula — Current State
*Last updated: 2026-06-10*
*Last updated: 2026-06-11*
## Status
**Milestone:** v0.5Calendar filter (M3) + Settings (M4) (complete)
**Phase:** All V1 screens and cross-cutting wiring done except jump-to-date
(M2), which is deferred to the v1.0 polish pass
**Milestone:** v0.6Full event read (complete)
**Phase:** All V1 screens done and the read model is now complete — the detail
view surfaces every readable `CalendarContract` field. Next up is a UI
polish/QA pass before v1.0
## Progress
@@ -21,10 +22,13 @@
- [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] 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
1. Jump-to-date (M2) — date picker from the drawer, reachable on every view
2. UI polish / QA pass across all views before v1.0
3. F-Droid release of v1.0
1. UI polish / QA pass across all views before v1.0
2. 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]
## [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
@@ -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
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 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
- **Appearance** — theme (System / Light / Dark), Material You dynamic colour
(auto-disabled below Android 12), week start (Automatic / Monday / Sunday)

View File

@@ -23,8 +23,8 @@ android {
applicationId = "de.jeanlucmakiola.calendula"
minSdk = 29
targetSdk = 36
versionCode = 5
versionName = "0.5.0"
versionCode = 6
versionName = "0.6.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

View File

@@ -13,6 +13,7 @@ import de.jeanlucmakiola.calendula.domain.Attendee
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.Reminder
import javax.inject.Inject
import javax.inject.Singleton
@@ -62,13 +63,14 @@ class AndroidCalendarDataSource @Inject constructor(
override fun eventDetail(eventId: Long): EventDetail? {
val attendees = queryAttendees(eventId)
val reminders = queryReminders(eventId)
return resolver.query(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
EventDetailProjection.COLUMNS,
null, null, null,
)?.use { c ->
if (!c.moveToFirst()) null
else CursorColumnReader(c).toEventDetailCore(attendees)
else CursorColumnReader(c).toEventDetailCore(attendees, reminders)
}
}
@@ -98,6 +100,14 @@ class AndroidCalendarDataSource @Inject constructor(
null,
)?.use { c -> c.mapAll { CursorColumnReader(c).toAttendee() } } ?: emptyList()
private fun queryReminders(eventId: Long): List<Reminder> = resolver.query(
CalendarContract.Reminders.CONTENT_URI,
ReminderProjection.COLUMNS,
CalendarContract.Reminders.EVENT_ID + " = ?",
arrayOf(eventId.toString()),
null,
)?.use { c -> c.mapAll { CursorColumnReader(c).toReminder() } } ?: emptyList()
private fun toCalendarSource(c: Cursor): CalendarSource = CursorColumnReader(c).toCalendarSource()
/** Iterate every row and map; skips nothing. */

View File

@@ -2,14 +2,24 @@ package de.jeanlucmakiola.calendula.data.calendar
import android.provider.CalendarContract
import android.util.Log
import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.Attendee
import de.jeanlucmakiola.calendula.domain.AttendeeRelationship
import de.jeanlucmakiola.calendula.domain.AttendeeStatus
import de.jeanlucmakiola.calendula.domain.AttendeeType
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.Reminder
import de.jeanlucmakiola.calendula.domain.ReminderMethod
private const val TAG = "EventDetailMapper"
internal fun ColumnReader.toEventDetailCore(attendees: List<Attendee>): EventDetail? {
internal fun ColumnReader.toEventDetailCore(
attendees: List<Attendee>,
reminders: List<Reminder>,
): EventDetail? {
val begin = getLong(EventDetailProjection.IDX_DTSTART)
if (begin < 0L) {
@@ -54,12 +64,28 @@ internal fun ColumnReader.toEventDetailCore(attendees: List<Attendee>): EventDet
location = getString(EventDetailProjection.IDX_LOCATION),
)
// STATUS shares its 0 value with STATUS_TENTATIVE, so a missing column must
// be distinguished from a present 0 — an absent status means "just confirmed".
val status = if (isNull(EventDetailProjection.IDX_STATUS)) {
EventStatus.Confirmed
} else {
mapEventStatus(getInt(EventDetailProjection.IDX_STATUS))
}
return EventDetail(
instance = instance,
description = getString(EventDetailProjection.IDX_DESCRIPTION),
organizer = getString(EventDetailProjection.IDX_ORGANIZER),
attendees = attendees,
rrule = getString(EventDetailProjection.IDX_RRULE),
reminders = reminders,
status = status,
// BUSY and DEFAULT are both 0, so a null column folds into the same
// default these mappers already return — no isNull guard needed.
availability = mapAvailability(getInt(EventDetailProjection.IDX_AVAILABILITY)),
accessLevel = mapAccessLevel(getInt(EventDetailProjection.IDX_ACCESS_LEVEL)),
eventTimezone = getString(EventDetailProjection.IDX_EVENT_TIMEZONE),
selfStatus = mapAttendeeStatus(getInt(EventDetailProjection.IDX_SELF_ATTENDEE_STATUS)),
)
}
@@ -67,6 +93,13 @@ internal fun ColumnReader.toAttendee(): Attendee = Attendee(
name = getString(AttendeeProjection.IDX_NAME).orEmpty(),
email = getString(AttendeeProjection.IDX_EMAIL),
status = mapAttendeeStatus(getInt(AttendeeProjection.IDX_STATUS)),
relationship = mapAttendeeRelationship(getInt(AttendeeProjection.IDX_RELATIONSHIP)),
type = mapAttendeeType(getInt(AttendeeProjection.IDX_TYPE)),
)
internal fun ColumnReader.toReminder(): Reminder = Reminder(
minutes = getInt(ReminderProjection.IDX_MINUTES),
method = mapReminderMethod(getInt(ReminderProjection.IDX_METHOD)),
)
internal fun mapAttendeeStatus(raw: Int): AttendeeStatus = when (raw) {
@@ -76,3 +109,46 @@ internal fun mapAttendeeStatus(raw: Int): AttendeeStatus = when (raw) {
CalendarContract.Attendees.ATTENDEE_STATUS_INVITED -> AttendeeStatus.NeedsAction
else -> AttendeeStatus.Unknown
}
internal fun mapAttendeeRelationship(raw: Int): AttendeeRelationship = when (raw) {
CalendarContract.Attendees.RELATIONSHIP_ORGANIZER -> AttendeeRelationship.Organizer
CalendarContract.Attendees.RELATIONSHIP_PERFORMER -> AttendeeRelationship.Performer
CalendarContract.Attendees.RELATIONSHIP_SPEAKER -> AttendeeRelationship.Speaker
CalendarContract.Attendees.RELATIONSHIP_ATTENDEE -> AttendeeRelationship.Attendee
else -> AttendeeRelationship.None
}
internal fun mapAttendeeType(raw: Int): AttendeeType = when (raw) {
CalendarContract.Attendees.TYPE_REQUIRED -> AttendeeType.Required
CalendarContract.Attendees.TYPE_OPTIONAL -> AttendeeType.Optional
CalendarContract.Attendees.TYPE_RESOURCE -> AttendeeType.Resource
else -> AttendeeType.None
}
internal fun mapEventStatus(raw: Int): EventStatus = when (raw) {
CalendarContract.Events.STATUS_CONFIRMED -> EventStatus.Confirmed
CalendarContract.Events.STATUS_TENTATIVE -> EventStatus.Tentative
CalendarContract.Events.STATUS_CANCELED -> EventStatus.Cancelled
else -> EventStatus.Confirmed
}
internal fun mapAvailability(raw: Int): Availability = when (raw) {
CalendarContract.Events.AVAILABILITY_FREE -> Availability.Free
CalendarContract.Events.AVAILABILITY_TENTATIVE -> Availability.Tentative
else -> Availability.Busy
}
internal fun mapAccessLevel(raw: Int): AccessLevel = when (raw) {
CalendarContract.Events.ACCESS_CONFIDENTIAL -> AccessLevel.Confidential
CalendarContract.Events.ACCESS_PRIVATE -> AccessLevel.Private
CalendarContract.Events.ACCESS_PUBLIC -> AccessLevel.Public
else -> AccessLevel.Default
}
internal fun mapReminderMethod(raw: Int): ReminderMethod = when (raw) {
CalendarContract.Reminders.METHOD_EMAIL -> ReminderMethod.Email
CalendarContract.Reminders.METHOD_SMS -> ReminderMethod.Sms
CalendarContract.Reminders.METHOD_ALARM -> ReminderMethod.Alarm
CalendarContract.Reminders.METHOD_ALERT -> ReminderMethod.Alert
else -> ReminderMethod.Default
}

View File

@@ -60,6 +60,11 @@ internal object EventDetailProjection {
CalendarContract.Events.ALL_DAY,
CalendarContract.Events.EVENT_LOCATION,
CalendarContract.Events.CALENDAR_ID,
CalendarContract.Events.STATUS,
CalendarContract.Events.AVAILABILITY,
CalendarContract.Events.ACCESS_LEVEL,
CalendarContract.Events.EVENT_TIMEZONE,
CalendarContract.Events.SELF_ATTENDEE_STATUS,
)
const val IDX_EVENT_ID = 0
@@ -74,6 +79,11 @@ internal object EventDetailProjection {
const val IDX_ALL_DAY = 9
const val IDX_LOCATION = 10
const val IDX_CALENDAR_ID = 11
const val IDX_STATUS = 12
const val IDX_AVAILABILITY = 13
const val IDX_ACCESS_LEVEL = 14
const val IDX_EVENT_TIMEZONE = 15
const val IDX_SELF_ATTENDEE_STATUS = 16
}
internal object AttendeeProjection {
@@ -81,11 +91,25 @@ internal object AttendeeProjection {
CalendarContract.Attendees.ATTENDEE_NAME,
CalendarContract.Attendees.ATTENDEE_EMAIL,
CalendarContract.Attendees.ATTENDEE_STATUS,
CalendarContract.Attendees.ATTENDEE_RELATIONSHIP,
CalendarContract.Attendees.ATTENDEE_TYPE,
)
const val IDX_NAME = 0
const val IDX_EMAIL = 1
const val IDX_STATUS = 2
const val IDX_RELATIONSHIP = 3
const val IDX_TYPE = 4
}
internal object ReminderProjection {
val COLUMNS: Array<String> = arrayOf(
CalendarContract.Reminders.MINUTES,
CalendarContract.Reminders.METHOD,
)
const val IDX_MINUTES = 0
const val IDX_METHOD = 1
}
internal object Fallbacks {

View File

@@ -29,12 +29,34 @@ data class EventDetail(
val organizer: String?,
val attendees: List<Attendee>,
val rrule: String?,
/** Reminders (VALARM) configured on the event, ascending lead time. */
val reminders: List<Reminder> = emptyList(),
/** Confirmed / Tentative / Cancelled (`Events.STATUS`). */
val status: EventStatus = EventStatus.Confirmed,
/** Busy / Free (`Events.AVAILABILITY`, the iCal TRANSP field). */
val availability: Availability = Availability.Busy,
/** Default / Private / Confidential / Public (`Events.ACCESS_LEVEL`). */
val accessLevel: AccessLevel = AccessLevel.Default,
/** Raw zone id (`Events.EVENT_TIMEZONE`), e.g. "Europe/Berlin"; null if unset. */
val eventTimezone: String? = null,
/** This device user's own response (`Events.SELF_ATTENDEE_STATUS`). */
val selfStatus: AttendeeStatus = AttendeeStatus.Unknown,
)
data class Attendee(
val name: String,
val email: String?,
val status: AttendeeStatus,
/** Organizer / performer / speaker / plain attendee (`ATTENDEE_RELATIONSHIP`). */
val relationship: AttendeeRelationship = AttendeeRelationship.None,
/** Required / optional / resource (`ATTENDEE_TYPE`). */
val type: AttendeeType = AttendeeType.None,
)
data class Reminder(
/** Lead time before the event start, in minutes. `-1` means the provider default. */
val minutes: Int,
val method: ReminderMethod,
)
enum class AttendeeStatus {
@@ -45,6 +67,48 @@ enum class AttendeeStatus {
Unknown,
}
enum class AttendeeRelationship {
Organizer,
Attendee,
Performer,
Speaker,
None,
}
enum class AttendeeType {
Required,
Optional,
Resource,
None,
}
enum class ReminderMethod {
Alert,
Email,
Sms,
Alarm,
Default,
}
enum class EventStatus {
Confirmed,
Tentative,
Cancelled,
}
enum class Availability {
Busy,
Free,
Tentative,
}
enum class AccessLevel {
Default,
Public,
Private,
Confidential,
}
enum class FailureReason {
PermissionRevoked,
NoCalendarsConfigured,

View File

@@ -13,6 +13,8 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -30,8 +32,10 @@ import androidx.compose.material.icons.automirrored.filled.Notes
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.People
import androidx.compose.material.icons.filled.Place
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Repeat
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -46,25 +50,36 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.Attendee
import de.jeanlucmakiola.calendula.domain.AttendeeRelationship
import de.jeanlucmakiola.calendula.domain.AttendeeStatus
import de.jeanlucmakiola.calendula.domain.AttendeeType
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.Reminder
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.currentLocale
import de.jeanlucmakiola.calendula.ui.common.pastelize
@@ -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.
// 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,
) {
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".

View File

@@ -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,26 +177,11 @@ private fun DeniedContent(
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
Column(
modifier = modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
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(
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)
@@ -123,8 +189,170 @@ private fun DeniedContent(
}
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>
<!-- Permission-Flow (F1) -->
<string name="permission_rationale_title">Kalender-Zugriff</string>
<string name="permission_rationale_body">Calendula liest nur deinen Gerätekalender — keine Daten verlassen das Gerät.</string>
<string name="permission_request_button">Weiter</string>
<string name="permission_rationale_title">Alle Termine, schön im Blick</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">Kalender-Zugriff erlauben</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_open_settings_button">System-Einstellungen öffnen</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) -->
<string name="month_prev">Vorheriger Monat</string>
@@ -66,6 +73,37 @@
<string name="event_attendee_needs_action">Keine Antwort</string>
<string name="event_attendee_unknown"></string>
<!-- Event-Detail — vollständiges Auslesen (v0.6) -->
<string name="event_detail_reminders">Erinnerungen</string>
<string name="event_detail_timezone">Zeitzone</string>
<string name="event_status_tentative">Vorläufig</string>
<string name="event_status_cancelled">Abgesagt</string>
<string name="event_availability_free">Frei</string>
<string name="event_access_private">Privat</string>
<string name="event_access_confidential">Vertraulich</string>
<string name="event_attendee_organizer">Organisator</string>
<string name="event_attendee_optional">Optional</string>
<string name="event_attendee_resource">Ressource</string>
<string name="event_detail_self_response">Deine Antwort: %1$s</string>
<string name="reminder_at_time">Zur Startzeit</string>
<string name="reminder_default">Standarderinnerung</string>
<plurals name="reminder_minutes">
<item quantity="one">%d Minute vorher</item>
<item quantity="other">%d Minuten vorher</item>
</plurals>
<plurals name="reminder_hours">
<item quantity="one">%d Stunde vorher</item>
<item quantity="other">%d Stunden vorher</item>
</plurals>
<plurals name="reminder_days">
<item quantity="one">%d Tag vorher</item>
<item quantity="other">%d Tage vorher</item>
</plurals>
<plurals name="reminder_weeks">
<item quantity="one">%d Woche vorher</item>
<item quantity="other">%d Wochen vorher</item>
</plurals>
<!-- Geteilte Event-Strings -->
<string name="event_untitled">(Ohne Titel)</string>

View File

@@ -13,13 +13,20 @@
<string name="state_failure_provider">Could not read the calendar.</string>
<!-- Permission flow (F1) -->
<string name="permission_rationale_title">Calendar access</string>
<string name="permission_rationale_body">Calendula reads only your device calendar — no data leaves your device.</string>
<string name="permission_request_button">Continue</string>
<string name="permission_rationale_title">See all your events, beautifully</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">Grant calendar access</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_open_settings_button">Open system settings</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) -->
<string name="month_prev">Previous month</string>
@@ -67,6 +74,37 @@
<string name="event_attendee_needs_action">No response</string>
<string name="event_attendee_unknown"></string>
<!-- Event detail — full read (v0.6) -->
<string name="event_detail_reminders">Reminders</string>
<string name="event_detail_timezone">Time zone</string>
<string name="event_status_tentative">Tentative</string>
<string name="event_status_cancelled">Cancelled</string>
<string name="event_availability_free">Free</string>
<string name="event_access_private">Private</string>
<string name="event_access_confidential">Confidential</string>
<string name="event_attendee_organizer">Organizer</string>
<string name="event_attendee_optional">Optional</string>
<string name="event_attendee_resource">Resource</string>
<string name="event_detail_self_response">Your response: %1$s</string>
<string name="reminder_at_time">At time of event</string>
<string name="reminder_default">Default reminder</string>
<plurals name="reminder_minutes">
<item quantity="one">%d minute before</item>
<item quantity="other">%d minutes before</item>
</plurals>
<plurals name="reminder_hours">
<item quantity="one">%d hour before</item>
<item quantity="other">%d hours before</item>
</plurals>
<plurals name="reminder_days">
<item quantity="one">%d day before</item>
<item quantity="other">%d days before</item>
</plurals>
<plurals name="reminder_weeks">
<item quantity="one">%d week before</item>
<item quantity="other">%d weeks before</item>
</plurals>
<!-- Shared event strings -->
<string name="event_untitled">(No title)</string>

View File

@@ -1,7 +1,14 @@
package de.jeanlucmakiola.calendula.data.calendar
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.AttendeeRelationship
import de.jeanlucmakiola.calendula.domain.AttendeeStatus
import de.jeanlucmakiola.calendula.domain.AttendeeType
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.Reminder
import de.jeanlucmakiola.calendula.domain.ReminderMethod
import org.junit.jupiter.api.Test
class EventDetailMapperTest {
@@ -19,6 +26,11 @@ class EventDetailMapperTest {
allDay: Int = 0,
location: String? = "Berlin",
calendarId: Long = 7L,
status: Any? = null,
availability: Any? = null,
accessLevel: Any? = null,
timezone: String? = null,
selfStatus: Any? = null,
): MapColumnReader = MapColumnReader(
EventDetailProjection.IDX_EVENT_ID to eventId,
EventDetailProjection.IDX_TITLE to title,
@@ -32,18 +44,42 @@ class EventDetailMapperTest {
EventDetailProjection.IDX_ALL_DAY to allDay,
EventDetailProjection.IDX_LOCATION to location,
EventDetailProjection.IDX_CALENDAR_ID to calendarId,
EventDetailProjection.IDX_STATUS to status,
EventDetailProjection.IDX_AVAILABILITY to availability,
EventDetailProjection.IDX_ACCESS_LEVEL to accessLevel,
EventDetailProjection.IDX_EVENT_TIMEZONE to timezone,
EventDetailProjection.IDX_SELF_ATTENDEE_STATUS to selfStatus,
)
private fun attendeeReader(name: String?, email: String?, status: Int): MapColumnReader =
private fun attendeeReader(
name: String?,
email: String?,
status: Int,
relationship: Int = 0,
type: Int = 0,
): MapColumnReader =
MapColumnReader(
AttendeeProjection.IDX_NAME to name,
AttendeeProjection.IDX_EMAIL to email,
AttendeeProjection.IDX_STATUS to status,
AttendeeProjection.IDX_RELATIONSHIP to relationship,
AttendeeProjection.IDX_TYPE to type,
)
private fun reminderReader(minutes: Int, method: Int): MapColumnReader =
MapColumnReader(
ReminderProjection.IDX_MINUTES to minutes,
ReminderProjection.IDX_METHOD to method,
)
private fun MapColumnReader.toDetail(
attendees: List<de.jeanlucmakiola.calendula.domain.Attendee> = emptyList(),
reminders: List<Reminder> = emptyList(),
) = toEventDetailCore(attendees, reminders)
@Test
fun `happy path detail maps all fields and embeds matching EventInstance`() {
val detail = detailReader().toEventDetailCore(attendees = emptyList())
val detail = detailReader().toDetail()
assertThat(detail).isNotNull()
assertThat(detail!!.description).isEqualTo("Body")
assertThat(detail.organizer).isEqualTo("x@y")
@@ -55,21 +91,19 @@ class EventDetailMapperTest {
@Test
fun `event color falls back to calendar color when null`() {
val detail = detailReader(eventColor = null, calendarColor = 0xFF112233.toInt())
.toEventDetailCore(attendees = emptyList())
.toDetail()
assertThat(detail!!.instance.color).isEqualTo(0xFF112233.toInt())
}
@Test
fun `dtend before dtstart drops detail`() {
val detail = detailReader(dtstart = 2000L, dtend = 1000L)
.toEventDetailCore(attendees = emptyList())
val detail = detailReader(dtstart = 2000L, dtend = 1000L).toDetail()
assertThat(detail).isNull()
}
@Test
fun `rrule passes through when present`() {
val detail = detailReader(rrule = "FREQ=WEEKLY;BYDAY=MO")
.toEventDetailCore(attendees = emptyList())
val detail = detailReader(rrule = "FREQ=WEEKLY;BYDAY=MO").toDetail()
assertThat(detail!!.rrule).isEqualTo("FREQ=WEEKLY;BYDAY=MO")
}
@@ -104,4 +138,82 @@ class EventDetailMapperTest {
assertThat(attendeeReader("A", null, 1).toAttendee().email).isNull()
assertThat(attendeeReader("A", "x@y", 1).toAttendee().email).isEqualTo("x@y")
}
// RELATIONSHIP: NONE=0, ATTENDEE=1, ORGANIZER=2, PERFORMER=3, SPEAKER=4
@Test
fun `attendee relationship maps known integer codes`() {
assertThat(attendeeReader("A", "a@x", 1, relationship = 2).toAttendee().relationship)
.isEqualTo(AttendeeRelationship.Organizer)
assertThat(attendeeReader("A", "a@x", 1, relationship = 1).toAttendee().relationship)
.isEqualTo(AttendeeRelationship.Attendee)
assertThat(attendeeReader("A", "a@x", 1, relationship = 0).toAttendee().relationship)
.isEqualTo(AttendeeRelationship.None)
}
// TYPE: NONE=0, REQUIRED=1, OPTIONAL=2, RESOURCE=3
@Test
fun `attendee type maps known integer codes`() {
assertThat(attendeeReader("A", "a@x", 1, type = 1).toAttendee().type)
.isEqualTo(AttendeeType.Required)
assertThat(attendeeReader("A", "a@x", 1, type = 2).toAttendee().type)
.isEqualTo(AttendeeType.Optional)
assertThat(attendeeReader("A", "a@x", 1, type = 3).toAttendee().type)
.isEqualTo(AttendeeType.Resource)
}
// STATUS: TENTATIVE=0, CONFIRMED=1, CANCELED=2
@Test
fun `event status null maps to confirmed, codes map through`() {
assertThat(detailReader(status = null).toDetail()!!.status).isEqualTo(EventStatus.Confirmed)
assertThat(detailReader(status = 0).toDetail()!!.status).isEqualTo(EventStatus.Tentative)
assertThat(detailReader(status = 1).toDetail()!!.status).isEqualTo(EventStatus.Confirmed)
assertThat(detailReader(status = 2).toDetail()!!.status).isEqualTo(EventStatus.Cancelled)
}
// AVAILABILITY: BUSY=0, FREE=1, TENTATIVE=2
@Test
fun `availability null or busy maps to Busy, free maps to Free`() {
assertThat(detailReader(availability = null).toDetail()!!.availability)
.isEqualTo(Availability.Busy)
assertThat(detailReader(availability = 0).toDetail()!!.availability)
.isEqualTo(Availability.Busy)
assertThat(detailReader(availability = 1).toDetail()!!.availability)
.isEqualTo(Availability.Free)
}
// ACCESS_LEVEL: DEFAULT=0, CONFIDENTIAL=1, PRIVATE=2, PUBLIC=3
@Test
fun `access level maps known integer codes, null is Default`() {
assertThat(detailReader(accessLevel = null).toDetail()!!.accessLevel)
.isEqualTo(AccessLevel.Default)
assertThat(detailReader(accessLevel = 1).toDetail()!!.accessLevel)
.isEqualTo(AccessLevel.Confidential)
assertThat(detailReader(accessLevel = 2).toDetail()!!.accessLevel)
.isEqualTo(AccessLevel.Private)
}
@Test
fun `event timezone and self status pass through`() {
val detail = detailReader(timezone = "Europe/Berlin", selfStatus = 1).toDetail()
assertThat(detail!!.eventTimezone).isEqualTo("Europe/Berlin")
assertThat(detail.selfStatus).isEqualTo(AttendeeStatus.Accepted)
}
@Test
fun `reminders pass through to the detail`() {
val reminders = listOf(Reminder(10, ReminderMethod.Alert))
val detail = detailReader().toDetail(reminders = reminders)
assertThat(detail!!.reminders).isEqualTo(reminders)
}
// METHOD: DEFAULT=0, ALERT=1, EMAIL=2, SMS=3, ALARM=4
@Test
fun `reminder maps minutes and method codes`() {
assertThat(reminderReader(10, 1).toReminder())
.isEqualTo(Reminder(10, ReminderMethod.Alert))
assertThat(reminderReader(60, 2).toReminder())
.isEqualTo(Reminder(60, ReminderMethod.Email))
assertThat(reminderReader(0, 0).toReminder())
.isEqualTo(Reminder(0, ReminderMethod.Default))
}
}

View File

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