feat: restore events from .ics + per-calendar export selector (#32) #62

Merged
makiolaj merged 16 commits from feat/ics-restore into release/v2.14.0 2026-07-06 19:45:03 +00:00
Owner

Implements in-app Restore from .ics (issue #32) plus a per-calendar export selector, with a full /code-review pass folded in. Targets release/v2.14.0 (merged into this branch).

Feature

  • Restore from .ics in Calendars → Backup: SAF open-document, parsed and deduped by UID, with a target-calendar picker. An in-app restore always runs the full import flow (picker + summary), even for a single-event file.
  • Per-calendar export selector: choose which local calendars go into a .ics backup.
  • One shared calendar picker across the event editor and the import screen (grouped account-header cards).
  • Managed special-dates (contact-mirror) calendars excluded from both export and import targets.
  • Redesigned import-complete screen (stat tiles + dedup note).

Review fixes (latest 3 commits)

  • Import ViewModel scoped per-uri — an unkeyed, Activity-retained VM replayed the previous file's state on a second import (worst case: writing file A's events after picking B). Keying by uri gives each file a fresh parse.
  • Restore availability broadened — offered whenever any writable, non-managed calendar exists (local or synced), no longer gated behind export eligibility.
  • Export-picker selection hardenedrememberSaveable and no longer keyed on the observer-driven calendars list, so a background provider re-emit can't reset the user's de-selections; survives rotation.
  • Blank-account displayName fallback restored; dead imports dropped; added repository tests for the export calendar-id plumbing.

Verification

  • ./gradlew test green; testDebugUnitTest compiles clean; check_translations.py consistent.
  • ⚠️ On-device review still pending for the UI changes (new Restore section, shared picker, import-complete screen) before release.

Deferred

  • Three now-orphaned string keys (import_target_header, import_event_count, import_action) are dead in code but still in base + values-de/es/it. Removing them by hand would make them stale keys (fatal in the translation check) and German is Weblate-owned — so they must be dropped via the Weblate source-string flow, not a hand edit.

🤖 Generated with Claude Code

Implements in-app **Restore from .ics** (issue #32) plus a **per-calendar export selector**, with a full `/code-review` pass folded in. Targets `release/v2.14.0` (merged into this branch). ### Feature - **Restore from .ics** in Calendars → Backup: SAF open-document, parsed and deduped by UID, with a target-calendar picker. An in-app restore always runs the full import flow (picker + summary), even for a single-event file. - **Per-calendar export selector**: choose which local calendars go into a `.ics` backup. - One **shared calendar picker** across the event editor and the import screen (grouped account-header cards). - Managed special-dates (contact-mirror) calendars excluded from both export and import targets. - Redesigned import-complete screen (stat tiles + dedup note). ### Review fixes (latest 3 commits) - **Import ViewModel scoped per-uri** — an unkeyed, Activity-retained VM replayed the *previous* file's state on a second import (worst case: writing file A's events after picking B). Keying by uri gives each file a fresh parse. - **Restore availability broadened** — offered whenever any writable, non-managed calendar exists (local *or* synced), no longer gated behind export eligibility. - **Export-picker selection hardened** — `rememberSaveable` and no longer keyed on the observer-driven calendars list, so a background provider re-emit can't reset the user's de-selections; survives rotation. - Blank-account `displayName` fallback restored; dead imports dropped; added repository tests for the export calendar-id plumbing. ### Verification - `./gradlew test` green; `testDebugUnitTest` compiles clean; `check_translations.py` consistent. - ⚠️ **On-device review still pending** for the UI changes (new Restore section, shared picker, import-complete screen) before release. ### Deferred - Three now-orphaned string keys (`import_target_header`, `import_event_count`, `import_action`) are dead in code but still in base + `values-de/es/it`. Removing them by hand would make them stale keys (fatal in the translation check) and German is Weblate-owned — so they must be dropped via the Weblate source-string flow, not a hand edit. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
makiolaj added 16 commits 2026-07-06 19:41:52 +00:00
Add a 'Restore from .ics file' row to the Calendars backup section, next
to Export. It opens a SAF document picker and routes the picked Uri into
the existing import flow (parse, dedup by UID, target-calendar picker,
summary) via CalendarHost's importUri — the same path an externally
opened .ics already takes, so no new import machinery is needed.

Closes #32.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The 'Add to calendar' picker rendered bare OptionCards with no calendar
colour and no account grouping. Reuse the same account-grouped GroupedRow
layout as the event editor's calendar picker — coloured chip per calendar,
account sub-headers, a check on the selected row — so it matches the rest
of the app.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extract CalendarPickerGroups into ui/common: the calendar-manager screen's
grouped-card system (device chip for local calendars, the owning app's
launcher icon per synced account, colour chip + check per calendar) as a
single reusable picker. Use it in both the event editor and the .ics import
screen so all 'which calendar' lists match.

Moves LeadingAvatar/SourceLogo/curatedSourcePackage out of CalendarsScreen
into common as the shared source of truth. Drops the redundant 'Add to
calendar' caption from the import picker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the plain centered text list with an M3 Expressive success state:
a tonal check badge that springs in, the headline, and big-number tonal
stat tiles for added / duplicate-skipped counts, with a full-width Done
button. Stat tiles carry the full-sentence plurals as accessibility
labels so TalkBack still reads 'Imported N events'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The contact special-dates mirror calendars (birthdays/anniversaries) are
derived from contacts and re-materialise from the contact sync, so backing
them up only duplicates events on restore. Skip managed calendars in
exportableEvents — covers both the manual export and the auto-backup, which
share this path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Symmetric with the export change: the contact-derived, editor-locked
special-dates mirror calendars aren't a valid import destination, so drop
them from the target-calendar picker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tapping Export with more than one exportable calendar now opens a picker
to choose which local calendars to include (all selected by default); a
single calendar exports straight through as before. Threads an optional
calendarIds filter through exportEvents/exportableEvents (null = all
eligible), so the auto-backup path is unaffected. The backup section is
now gated on there being at least one exportable (non-managed) calendar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The import de-dups by UID against the target calendar (idempotent restore),
so re-importing events already present shows a low 'Added' count. Add a note
under the title when any were skipped so the outcome doesn't read as broken.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Route by entry point, not just event count. Opening a .ics from outside
still sends a single event straight to the prefilled create form (add one
event, e.g. a ticket). The in-app 'Restore from .ics' button passes
forceMany so even a single-event backup goes through the calendar picker +
summary — its intent is 'restore a backup', not 'add this event'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the multi-event import's confirm button into the app-bar actions so
it's reachable without scrolling past a long calendar list, and put the
count in the title ('Importing 5 events') instead of a separate 'N events
in this file' line. Hoists the selected target calendar to the screen so
the top-bar action can read it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The pre-selected target was calendars.first() (raw provider order), which
could land on a synced calendar mid-list while the picker shows local
calendars first. Default to the first local calendar so the checkmark lines
up with the top row; fall back to the first calendar when none are local.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ImportScreen has no nav backstack, so an unkeyed hiltViewModel() resolved
to the Activity's ViewModelStore and was retained across imports. Its
one-shot `load` guard then showed the *previous* file's parsed state on
the next import — trivially reachable now that the in-app Restore button
lets you export→restore or restore twice in one session (worst case: the
picker still holds file A, so tapping Import writes A's events after you
picked B). Keying the VM by the file uri hands each distinct file a fresh
VM (fresh Loading state); the same uri (rotation) reuses it and holds the
result.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Restore is import, not export: offer it whenever any writable, non-
  managed calendar exists (local or synced), not only when there is a
  local calendar to back up. Previously the row lived inside the
  export-gated block and vanished for users with only a writable synced
  calendar, despite import supporting that target.
- Export-picker selection now uses rememberSaveable and is no longer
  keyed on the observer-driven calendars list, so a background provider
  re-emit (sync/recolor) can't silently reset the user's de-selections,
  and the choice survives rotation.
- Shared calendar picker: restore the displayName fallback for a synced
  calendar whose account name and type are both blank (was grouping them
  under an empty header).
- Drop imports left dead by the CalendarPickerGroups extraction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The fake now records the calendarIds it receives; two repository tests
assert exportEvents forwards a chosen subset and defaults to null (all
eligible calendars), closing the coverage gap for the per-calendar
export selector.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Merge remote-tracking branch 'origin/release/v2.14.0' into feat/ics-restore
All checks were successful
Translations / check (pull_request) Successful in 5s
CI / ci (pull_request) Successful in 9m46s
d6bc660983
makiolaj merged commit 60fcc6b64c into release/v2.14.0 2026-07-06 19:45:03 +00:00
makiolaj deleted branch feat/ics-restore 2026-07-06 19:45:03 +00:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: makiolaj/calendula#62