43 Commits

Author SHA1 Message Date
cfa25b9730 release: cut 0.3.1 — fix the launch crash in minified builds (#1)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 7s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Successful in 11m31s
2026-07-20 18:36:47 +02:00
9d7fc64b0b release: cut 0.3.1 — fix the launch crash in minified builds
R8 kept androidx.work.impl.WorkDatabase_Impl but pruned its no-arg
constructor: nothing calls it directly, Room only reaches it reflectively.
Room then threw InstantiationException, surfacing as "Failed to create an
instance of androidx.work.impl.WorkDatabase". WorkManager builds that
database from a startup ContentProvider, so 0.3.0 died before any of our
code ran — every install, every launch.

We don't depend on WorkManager directly; it arrives via Glance. AGP 9's
stricter R8 is what tipped this over, which is why 0.3.0 was the first
release to hit it.

Keep the Room no-arg constructor, and the ListenableWorker constructor
alongside it — same pruning hazard on the path WorkManager uses to
instantiate workers by name, which would have bitten once a Glance widget
update actually ran.

Reproduced the reporter's stack trace frame-for-frame on a releaseTest
build, then confirmed it launches clean afterwards. Fixes #1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 18:35:41 +02:00
4aa65edb45 ci(release): push tag to Codeberg before creating the release
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
The first fix didn't help: the pipeline creates the tag via the Gitea
API, and the push mirror (sync_on_commit only fires on real git pushes)
doesn't propagate an API-created tag promptly. So the Codeberg release
POST still raced the mirror and 500'd on a commit/tag Codeberg hadn't
received (0.2.1 and 0.3.0 both shipped everywhere but Codeberg).

Push the tag straight to Codeberg from the runner (guaranteed present),
then attach the release to that existing tag with no target_commitish —
which is what Forgejo 500s on. Race-free.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 22:48:29 +02:00
a9843e25b7 release: cut 0.3.0
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 7s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Successful in 12m27s
Ships the M5 reminders onboarding + Settings screen alongside the reworked
overview (Today progress ring, live Upcoming preview, unfurling search) and
the real launcher mark.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 22:25:14 +02:00
5f4711dabe ui: move the action press flourish onto the shape, spin the gear
Some checks failed
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 7s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been cancelled
The press animation turned the glyph; put it on the scalloped cookie
container instead and make it stronger (scale 1→0.82, rotate 0→40°). The
glyph holds upright via a counter-rotation, so the shape spins while a
magnifier or list icon stays readable. New spinIcon opts a glyph into its own
quarter turn — the settings gear uses it, so it reads as a gear cranking.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 22:21:29 +02:00
6d3fbc05c1 home: unfurl search from a fixed search icon, drop the title
Rework the top bar into a custom row with no app title (the launcher icon
already names the app). Settings stays pinned at the right and the search
action sits just left of it; neither moves. Tapping search unfurls a pill
leftward from the magnifier (expandHorizontally anchored at the end) holding
the auto-focused query field and a clear button, with the search icon
remaining as the bar's fixed trailing icon. Results render over the home
content as you type; a blank query leaves the home screen visible. Tapping
the icon again or system back closes search; the FAB hides while searching.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 22:21:29 +02:00
721b411579 icon: centre the launcher mark on the canvas
The first pass shifted the mark off-centre. Scale 0.66 about the canvas
centre, and centre the task CARD (not the card+bloom bounding box, which the
overhanging bloom badge drags low): pivot the Y-scale at the card's centre
(y=242.76) and translate +13.24 so the card sits dead-centre, horizontally
and vertically, with the bloom badging out to the lower-right.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 22:21:29 +02:00
046b8f7e9e home: move search behind a top-bar action, not an always-on bar
The inline search bar was permanently visible. Replace it with a search
action button (a 6-sided cookie shape, sibling to the settings cookie) to
the left of settings: the search bar is absent until tapped, then opens
expanded and auto-focused, covering the home content with live results.
Back arrow or system back closes it; the FAB hides only while searching.
Pass windowInsets = 0 so the bar, already below the app bar, does not
re-apply the status-bar inset and float with a large top gap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 22:21:29 +02:00
06cc9b1c8b icon: real launcher mark — task card, check, calendula bloom
Replace the placeholder check-mark foreground with the finished agendula
mark (converted from design/icon/agendula_icon.svg): a rounded line-art task
card with a check, plus a small Calendula bloom badge in the open
bottom-right corner — the sibling of Calendula's calendar mark.

Strokes render in Calendula's off-white (#FAF6F0) over agendula's existing
plum background (#7A5C6B, the hue-rotated counterpart of Calendula's slate),
so the two apps read as a family while staying distinct. Scaled 0.66 to
match Calendula's footprint and ~2.8dp stroke weight; reused as the
<monochrome> slot for themed icons.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 22:21:29 +02:00
2d366a7be3 home: inline expanding search over all tasks
Add a Material 3 SearchBar overlaying the top of the overview. Collapsed it
is a "Search tasks" bar below the title row; tapping expands it in place to
cover the home content with live results, filtering every task (open and
completed) by title, case-insensitive. The leading icon flips to a back
arrow while expanded, a clear button empties the query, and the FAB hides so
it does not float over the results. Results reuse the upcoming preview row.

ListsViewModel.Content now carries allTasks (open + completed) as the search
corpus; filtering stays in memory so the provider query is untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 22:21:29 +02:00
52aeebcb53 home: Today progress ring + live Upcoming preview
Rework the overview's 2x2 smart grid into a daily-momentum layout:

- Promote Today into a full-width hero with an M3 Expressive
  CircularWavyProgressIndicator over "x of y done" for tasks due today.
  Empty/all-done states read as a calm finished state, not a bare 0.
- Drop the white "Upcoming 0" tile (it shouted loudest while carrying the
  least) in favour of a live preview of the next few upcoming tasks, each a
  slim row with the quiet meta line and a tap-through to the task.
- Overdue + All fall back to a 2-up of the existing tonal tiles.

ListsViewModel now combines a Smart(COMPLETED) flow so the ring can count
the tasks already ticked off today (the open smart lists drop them).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 22:21:29 +02:00
8f6b85008c release: cut 0.2.2
Some checks failed
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been cancelled
Re-cut after 0.2.1's Codeberg publish failed (500 on pre-synced tag,
fixed in the previous commit). Validates the fix end to end and ships
the Codeberg direct-download channel (signed APK + SHA-256).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 22:14:57 +02:00
eb1e530ce7 ci(release): fix Codeberg publish 500 on pre-synced tag
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
The push mirror (sync_on_commit) syncs the tag to Codeberg before the
publish step runs, and Forgejo returns HTTP 500 on POST /releases with a
target_commitish when the tag already exists — so the release was never
created (v0.2.1 shipped to Gitea/F-Droid but not Codeberg). Only pass
target_commitish when the tag isn't mirrored yet; otherwise attach the
release to the existing tag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 22:07:37 +02:00
7516972e9f release: cut 0.2.1
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Successful in 12m13s
Publish releases to the Codeberg mirror (signed APK + SHA-256 checksum)
as a direct-download channel. Bumps versionName to 0.2.1; the pipeline
mints the v0.2.1 tag on merge to main.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 21:53:49 +02:00
7f58f81fe1 ci: adopt the modern calendula pipeline + Codeberg mirror
Port Calendula's current CI/release pipeline:

- ci.yaml: pull_request-triggered, change-scope classification
  (docs/metadata-only PRs skip the Android build but still report a
  green CI), and a reproducible-release invariant guard.
- release.yaml: the committed versionName is the source of truth — a
  bump reaching main triggers the release, which builds, signs,
  publishes to the F-Droid repo, then mints the vX.Y.Z tag + Gitea
  release and mirrors it to Codeberg with the signed APK + SHA-256
  checksum. workflow_dispatch runs the re-sign-only recovery path.
- Gitea releases are flagged as pre-releases while MAJOR is 0.
- build.gradle.kts: reproducible-release invariants (vcsInfo,
  dependenciesInfo) + a releaseTest variant for the on-device gate.
- fastlane/ becomes the single source of truth for store metadata;
  the localized F-Droid layout is generated from it at release time.
- Port scripts/, .gitea/ISSUE_TEMPLATE/, and rewrite docs/RELEASING.md
  for the versionName-in-main model; fix stale references elsewhere.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 21:53:13 +02:00
b266653e4e chore: bump floret-kit submodule to latest (component superset)
All checks were successful
CI / ci (push) Successful in 4m0s
Adopt floret-kit main @ 566caf4, picking up the grown GroupedRow / OptionPicker / CollapsingScaffold family superset. App compiles and unit tests pass against it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:13:21 +02:00
2f5012dc34 tasks: consolidate add affordance into a setting (FAB or bottom bar)
All checks were successful
CI / ci (push) Successful in 4m8s
Removes the doubled add idiom (top inline-add bar + FAB). A new "Bottom quick-add bar" setting (default off) picks one affordance per screen: off keeps the floating New task button everywhere; on shows a quick-add field pinned to the bottom of a real list (smart lists keep the button, as they have no single target list).

The bottom field floats on the brightest container tone with a shadow so it stands off the task cards, rides above the keyboard (ime ∪ navigation-bar insets), and drops focus when the IME hides so no stray cursor lingers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:01:32 +02:00
ccc07e86c7 tasks: quiet single-line row meta, lazy subtasks in smart lists, polish
- Collapse a task row's supporting info (priority, due, subtask progress,
  list name) into one low-emphasis meta line instead of stacked filled
  chips: priority is a tinted flag, the list name carries its list colour,
  and dates are compact (formatDateTimeCompact).
- Expand subtasks in smart lists too, by lazily fetching a parent's full
  child set on demand (TasksRepository.subtasks + the view model's live
  lazyChildren), while per-list views stay query-free.
- animateItem on the rows so (un)expanding a group fades its subtasks
  in/out and slides the rows below — clean whether the children were
  already loaded or arrive a frame later.
- Sit the undo chip beside the FAB at its height rather than overlapping
  or floating above it.

Bumps the floret-kit submodule for formatDateTimeCompact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 20:47:50 +02:00
211450bc46 chore: bump floret-kit submodule to merged main
All checks were successful
CI / ci (push) Successful in 7m24s
Picks up the pushed floret-kit main (the eight shared-kit commits merged with
the upstream docs/changelog/license commit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 19:20:56 +02:00
9d134be621 nav: fix blank back-gesture preview — let navigation drive predictive back
The per-screen Modifier.predictiveBack scaled the leaving screen with a
graphicsLayer, but NavHost only composes one back-stack entry at a time, so
nothing was drawn behind the shrinking screen — the gesture revealed the white
window background instead of the previous screen.

Drive predictive back through navigation-compose instead: it composes the
destination being revealed and seeks popExit with the gesture. popExit is now a
scaleOut + fade peek, so the leaving screen shrinks to reveal the real previous
screen held static. Drops the predictiveBack modifier from the destinations and
Settings (its inner section BackHandler is unchanged). The kit's predictiveBack
stays for overlay-style screens.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 19:15:24 +02:00
37e3c4e659 settings: use the shared AboutCard + LanguagePickerRow
Drop the in-app AboutCard, LanguageRow and languageLabel in favour of the
floret-kit recipes; the app still supplies its logo, strings, links and row
icon. Bumps the submodule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:24:17 +02:00
78632865f9 edit: use the shared OptionalFormSection in the task form
Drop the in-app copy in favour of floret-kit's. Bumps the submodule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:17:57 +02:00
e7a62e71df reminders: use the shared DialogAmountField in the custom lead editor
Replace the inline tonal amount field in the reminder picker with floret-kit's
DialogAmountField. Bumps the submodule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:15:08 +02:00
4c05c86e95 motion: use floret-kit's expandEnter/collapseExit for revealable sections
Replace the inline expandVertically/shrinkVertically + fade in the reminder
picker, task edit form and settings with the shared transitions — which adds
reduced-motion support and the Expressive springs. Bumps the submodule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:13:06 +02:00
37662e83bb nav: swap the full slide-back for the predictive-back peek
Back was a full-width horizontal slide-off — too heavy. Replace it with
floret-kit's Modifier.predictiveBack on each pop-able destination (task list,
detail, edit) so the leaving screen scales/rounds with the gesture and the held
screen is revealed unmoved; NavHost now does no pop animation. Settings applies
it at the hub only (enabled = section == null) so its inner section back still
returns to the hub. Forward stays a calm fade-in.

Bumps the floret-kit submodule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 14:31:20 +02:00
63c6191676 onboarding: draw the onboarding scaffold from floret-kit
Use components' OnboardingScaffold / BenefitRow / OnboardingSpace and drop the
in-app copies; keep Agendula's own SquircleHero (now in OnboardingHero.kt).
Bumps the floret-kit submodule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 14:16:56 +02:00
4994f5ec2c reminders + locale: draw prefs/reminders and language plumbing from floret-kit
Consume two newly extracted floret-kit modules and drop the in-app copies:

- core-reminders: ReminderUnit / decomposeReminderMinutes, the ReminderOverride
  model and the override codec. SettingsPrefs keeps its own DataStore wiring and
  Agendula's ':' codec dialect; ReminderFormatting/ReminderLeadPicker keep the
  app's R.string labels and presets.
- core-locale: AppLanguage. Deleted the app copy; SettingsScreen now passes
  R.xml.locales_config to supportedTags().

Bumps the floret-kit submodule to include both modules.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 14:12:50 +02:00
e4c8dbf2c1 build: drop the foojay toolchain resolver (reproducible-build safe)
All checks were successful
CI / ci (push) Successful in 8m7s
The Gradle Java-toolchain auto-download resolver can fetch a JDK at build time,
which an offline / reproducible F-Droid build scanner rejects. It was unused —
modules set jvmTarget directly with no toolchain block — so removing it is a
no-op for the build and brings Agendula in line with floret-kit's no-resolver
rule. Clean assembleDebug verified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 13:34:55 +02:00
069ae38b2c components batch-2: draw CollapsingScaffold + OptionPicker from floret-kit
All checks were successful
CI / ci (push) Successful in 8m37s
Agendula now sources the collapsing settings/sub-screen scaffold and the
full-screen single-select picker (FullScreenPicker/OptionPicker) from the kit's
components module. Deletes the app copies; repoints SettingsScreen imports and
adds the FullScreenPicker import in ReminderLeadPicker. Submodule re-pinned.
ReminderFormatting stays app-local (needs a label-callback API — separate pass).

Clean composite build + tests + lintDebug + debug assemble green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 13:25:53 +02:00
a5ddf16537 components: draw the shared Compose vocabulary from floret-kit
Some checks failed
CI / ci (push) Has been cancelled
Phase 4 (components). Agendula now sources its grouped-row primitive
(GroupedSurface/GroupedRow + Position/positionOf), InlineTextField, OptionCard,
and pastelize() from the kit's components module instead of its own ui/common
copies. These were Agendula's versions, taken as the canonical family reference
(design sign-off); Agendula keeps its own identity chips (ListColorChip/
ListNameChip task glyph, PriorityChip) which now import the shared pastelize.

- Delete app copies of GroupedList/InlineTextField/OptionCard; drop the local
  pastelize from ListChip. Repoint all imports (cross-package + same-package +
  one inline FQN) to de.jeanlucmakiola.floret.components. Submodule re-pinned.

Clean composite build + tests + lintDebug + debug assemble green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 13:09:37 +02:00
18e03e27b6 identity: draw the M3 Expressive theme factory + nav slide from floret-kit
All checks were successful
CI / ci (push) Successful in 7m0s
Phase 3 (identity layer). AgendulaTheme is now a thin wrapper over the kit's
FloretExpressiveTheme(lightScheme, darkScheme, …): the shared mechanics
(dynamic colour, light/dark, standard motion scheme) live in floret-kit, while
Agendula keeps its own identity — the seed-derived AgendulaLight/DarkFallback
schemes and AgendulaTypography (unchanged in Color.kt/Type.kt). rememberNavSlideSpec
now comes from the kit; the app's AgendulaTransitions.kt is removed and the
NavHost import repointed. Submodule re-pinned to the identity kit commit.

Clean composite build + tests + lintDebug + debug assemble green; substitution
confirmed (de.jeanlucmakiola.floret:identity -> project :floret-kit:identity).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 12:59:56 +02:00
152226c1b2 Phase 2: core-crash from floret-kit; complete the core-time wiring
All checks were successful
CI / ci (push) Successful in 9m16s
Adds crash reporting to Agendula by drawing the kit's new core-crash module
(first Android-library module in floret-kit), and finishes the core-time wiring
that a Phase 0 staging slip left uncommitted on main.

core-crash (NEW capability — Agendula had none):
- CrashReporter.install() in AgendulaApp captures uncaught exceptions on-device
  (allowlist-only report, chains to the platform handler, uploads nothing).
- MainActivity routes to a standalone CrashReportActivity on a startup crash-loop,
  and surfaces a single captured crash as a dialog on next launch; markHealthy on
  resume. App label + issue-tracker URL flow in via CrashConfig.
- Thin app-side CrashReportActivity uses AgendulaTheme; the reusable machinery
  (reporter, dialog, submit, config) lives in the kit. Submodule re-pinned to the
  core-crash kit commit.

Completes Phase 0 (was only partially committed in ec7b696):
- Repoint DayWindow + Instant formatting imports to de.jeanlucmakiola.floret.time
  (the app copies were deleted in ec7b696 but the imports/includeBuild/dependency
  were never staged, leaving main non-building).
- settings.gradle.kts includeBuild("floret-kit"); app depends on core-time.

Also fixes the CI checkout YAML: the submodules block was mis-indented under
'uses:' (invalid step mapping) — dedented to a proper sibling.

Clean composite build + unit tests + lintDebug + debug assemble green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 12:24:49 +02:00
ec7b696eb9 Draw core-time from floret-kit via submodule + composite build
Some checks failed
CI / ci (push) Failing after 2m12s
Phase 0 of the shared-kit plan: floret-kit is embedded as a git submodule and
wired in with Gradle includeBuild("floret-kit"), so the app depends on
de.jeanlucmakiola.floret:core-time and Gradle substitutes the local source
module (built from source — F-Droid-safe). Proves the submodule + composite-build
pipeline before any design layers move.

- Remove app copies of DayWindow + Instant date/time formatting (now in
  core-time); repoint imports to de.jeanlucmakiola.floret.time.
- DateTimeField gains an explicit import (was same-package).
- CI checkouts fetch submodules recursively.
- Clean composite build + unit tests + debug assemble green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 23:27:49 +02:00
76a0139fda Rename app Floret → Agendula
All checks were successful
CI / ci (push) Successful in 8m33s
Floret is promoted to the family / design-language (shared-kit) name; the
tasks app itself becomes Agendula (de.jeanlucmakiola.agendula) — agenda
('things to be done') + Calendula's -ula, a twin of the Calendula name.

Renames the package, namespace, applicationId, rootProject.name, app_name,
FloretApp/FloretNavHost/FloretTransitions classes, theme, F-Droid metadata
dir, CI artifact name, and docs. The botanical word 'florets' is preserved in
the name-origin prose, which is rewritten to Agendula's etymology. Clean
build + unit tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 23:10:24 +02:00
d84ac60757 M5: reminders onboarding, Settings screen & per-list overrides
Reminders onboarding & polish, plus the Settings screen (landed early).

- One-time reminder onboarding gate after the provider grant
  (ReminderOnboardingScreen + OnboardingScaffold), requesting
  POST_NOTIFICATIONS on API 33+; choice recorded in prefs.
- Settings screen, structured after Calendula as a category hub with
  sliding sub-screens (CollapsingScaffold + grouped rows + full-screen
  OptionPicker): About card, Appearance (theme, dynamic colour), Task
  form (default edit-form fields, default list, add-a-subtask-row
  opt-out), Reminders, Language, and Report a problem. Reached via an
  expressive shaped action button (MaterialShapes.Cookie4Sided) on the
  lists overview. Back from a sub-screen returns to the hub.
- Reminders: master enable toggle (gates the whole engine; re-requests
  the notification permission), a default lead time with a Custom
  amount/unit editor, an Android-12 exact-alarm status row, and
  per-list overrides of the default (Inherit / None / custom lead),
  honoured by ReminderScheduler per task.
- Per-app language (AppLanguage + locales_config.xml + appcompat
  locales service); add-a-subtask-row opt-out wired into the task list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 23:03:26 +02:00
2c457f3915 release: 0.2.0
All checks were successful
CI / ci (push) Successful in 6m18s
Release — F-Droid repo + Gitea release / ci (push) Successful in 7m21s
Release — F-Droid repo + Gitea release / gitea-release (push) Successful in 5s
Release — F-Droid repo + Gitea release / build-and-deploy (push) Successful in 6m14s
Promote [Unreleased] → [0.2.0] in the changelog (M2 UI + M3/M4 + fixes) and
bump the dev-default version to 0.2.0 / 200. The pushed tag drives the released
version per docs/RELEASING.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 21:27:06 +02:00
e0c56a73e2 M4: percent-complete, conflict-safe saves & subtask reparent
All checks were successful
CI / ci (push) Successful in 6m49s
M3 detail/edit polish:
- "Progress" slider on the edit form writes Tasks.PERCENT_COMPLETE (clamped
  0-100, 5% detents); status stays owned by the complete toggle.
- Conflict-safe saves: updateTask re-checks the provider's last_modified against
  the value captured when the form loaded and throws TaskConflictException; the
  editor offers overwrite-or-cancel instead of clobbering an external change.

M4 subtasks (UI):
- Reparent: a "Parent task" picker files a task under any top-level task in its
  list (or "None" to promote it); candidates stay top-level to keep nesting one
  level deep. Switching list clears the now-invalid parent.
- Tapping a subtask opens its own detail (a new TaskDetail entry, own VM).

Tests: percent clamping, parent-id write, populatedFields reveal logic.
Docs: ROADMAP M2/M3/M4 reconciled to the codebase; CHANGELOG [Unreleased].

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 21:15:15 +02:00
3db553da85 detail: always show the subtasks group so add is reachable
All checks were successful
CI / ci (push) Successful in 8m38s
Render the subtasks group on every task, not just ones that already
have children, so the inline add field is available from the start.
Hide the done/total count until there's at least one subtask.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:03:12 +02:00
11f9649dd7 M3: rebuild task create/edit form in Calendula's card language
Replace the boxed-text-field form with the family's tonal-card layout:
Close + filled SAVE top bar, a borderless headline title with a
list-colour accent bar, a "When" card (all-day switch + tappable
start/due rows), a tappable list card and reminder card that open
OptionCard dialogs, and segmented-button priority.

Optional Description / Priority / Reminder sections unfold from a
"More fields" picker (Calendula's disclosure): TaskFormField enum +
populatedFields() auto-reveal fields that already carry a value, and a
persisted defaultEditFields pref decides which start open (the settings
UI to edit it lands with M6). New shared InlineTextField + OptionCard;
date/time conversion helpers in DateTimeField promoted to internal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:03:02 +02:00
3397e57794 UI: fix subtask progress chip on the task list
Count a parent's subtask progress over the full task set in the
repository (attached to Task as subtaskTotal/subtaskDone) so completed
children are counted even on smart lists that hide closed tasks — the
chip showed "0 / 2" instead of "18 / 20". List membership (and the home
overview counts) are unchanged; only the parent's derived progress is.

Also wrap the task-row metadata in a FlowRow so the subtask chip drops
to a further line when a dated task with subtasks can't fit due + chips
on one line, rather than squeezing the trailing chip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 10:25:08 +02:00
24cf8fe331 nav: spring slide/fade screen transitions (Calendula feel)
Drive the NavHost with the motion scheme's fastSpatialSpec: forward fades in
over a held, static background; back (and the predictive-back drag) slides the
top screen out to the right, revealing the screen beneath unmoved — the same
overlay feel as Calendula, instead of the default navigation-compose transition.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:03:45 +02:00
a19b1373a4 M2: build out task list, detail & edit screens
Replace the base scaffolds with the real Material 3 Expressive UI on the
existing M1 ViewModels.

Task list: due-date section headers (collapsible Completed), grouped tonal
rows with a list-colour accent bar, swipe-to-complete / -delete, inline add,
list-name chip and subtask "done/total" chip in mixed views (children folded
into their parent). New pure TaskSections grouping + unit test.

Detail: each fact its own tonal card with a leading icon (Calendula pattern) —
when/list/priority/progress/description; progress shows only with subtasks.
Subtasks are a connected grouped run whose first segment is the expand toggle,
with an accent-tinted inline add row; completed subtasks sink to the bottom.

Edit: title, description, list dropdown, start/due date-time + all-day,
priority segmented buttons, reminder offset, validation; IME auto-scroll.

Extract GroupedSurface from GroupedRow so the connected-row look is reusable
at any width; add ListNameChip + date-time format/picker helpers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:03:37 +02:00
b196d9ebe0 M2: wire navigation host + base screen scaffolds
Replace the RootScreen nav stubs with a real NavHost. Each destination
binds its existing M1 ViewModel from route args.

- ui/navigation: Dest route table (string-based; no serialization plugin)
  + FloretNavHost (lists -> task list -> detail / edit).
- Base scaffolds for TaskListScreen, TaskDetailScreen, TaskEditScreen,
  wired to their ViewModels — skeletons for the rich M2/M3 UI.
- RootScreen READY branch now hands off to FloretNavHost.
- Add navigation-compose 2.9.0; supporting strings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 09:44:18 +02:00
117 changed files with 7775 additions and 1472 deletions

View File

@@ -0,0 +1,23 @@
---
name: Bug report
about: Something doesn't work the way it should
title: ""
labels:
- bug
---
### What happened
### What you expected
### Steps to reproduce
1.
2.
3.
### Environment
- Agendula version: <!-- Settings → bottom of the screen -->
- Android version:
- Device:

View File

@@ -0,0 +1,27 @@
---
name: Crash report
about: Report a crash. Agendula can capture this for you (Settings → Report a problem, or the prompt after a crash) — it copies the report to your clipboard and prefills this form.
title: "Crash: "
labels:
- bug
- crash
- priority:high
---
<!--
Thanks for reporting a crash in Agendula!
If the app prefilled this for you, the crash report is already below — just add
what you were doing and submit. Otherwise, paste the report from your clipboard
into the code block. The report contains only app/Android/device versions and the
stack trace — no personal data or calendar content.
-->
### What happened
### Crash report
```
(paste the crash report here)
```

View File

@@ -0,0 +1,16 @@
---
name: Feature request
about: Suggest an idea or improvement
title: ""
labels:
- feat
---
### What would you like Agendula to do?
### Why — what problem does it solve?
### Anything else
<!-- mockups, examples from other apps, alternatives you considered -->

View File

@@ -0,0 +1,19 @@
---
name: Question
about: Ask how something works or get help using Agendula
title: ""
labels:
- question
---
### Your question
### What you've tried
<!-- so far, if anything -->
### Context
- Agendula version: <!-- Settings → bottom of the screen -->
- Android version:
- Device:

View File

@@ -1,18 +1,23 @@
name: CI name: CI
# One gate per pull request. Branch pushes no longer trigger CI on their own,
# so a change is built once on its PR (covering feature -> release/* and
# release/* -> main) instead of once per push and again on the merge to main.
# The merge itself is handled by release.yaml, which only does heavy work when
# the merge actually cuts a release.
on: on:
push: pull_request:
branches:
- '**'
tags-ignore:
- '**'
# Cancel superseded runs on the same branch. # Cancel superseded runs for the same PR.
concurrency: concurrency:
group: ci-${{ github.ref }} group: ci-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
# Single job named `ci` so the required "CI" status check is always reported,
# even for docs-only PRs: those just skip the Android build and the job still
# succeeds (fast green check) instead of being filtered out and leaving the
# required check pending forever.
ci: ci:
runs-on: docker runs-on: docker
env: env:
@@ -21,14 +26,54 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
# Full history so the base..HEAD diff below has a merge-base.
fetch-depth: 0
submodules: recursive
# Cheap, always-on guard: the release build must stay reproducible for the
# official F-Droid repo (no AGP VCS-info embedding). Runs regardless of
# change scope so a regression can't slip through on a "docs-only" PR.
- name: Reproducible-release invariant
run: bash scripts/check_reproducible_release.sh
# Decide whether anything that affects the app build changed. Docs,
# F-Droid metadata and the licence don't, so those PRs skip the SDK +
# Gradle work below but still report a green `ci`.
- name: Classify change scope
id: scope
run: |
set -e
BASE="${{ github.base_ref }}"
# Full (not --depth=1) base fetch so the merge-base is present even when
# the PR branch forked several commits back; a shallow tip has no merge
# base with a divergent branch and `git diff base...HEAD` aborts.
git fetch --no-tags origin "$BASE"
MB=$(git merge-base "origin/$BASE" HEAD 2>/dev/null || true)
if [ -z "$MB" ]; then
# No common ancestor available — don't risk skipping the build.
echo "No merge base with origin/$BASE — running the full build to be safe."
echo "code=true" >> "$GITHUB_OUTPUT"
exit 0
fi
CHANGED=$(git diff --name-only "$MB" HEAD)
echo "Changed files:"; echo "$CHANGED"
if echo "$CHANGED" | grep -vE '(\.md$|^docs/|^fdroid-metadata/|^fastlane/|^LICENSE$)' | grep -q .; then
echo "code=true" >> "$GITHUB_OUTPUT"
else
echo "code=false" >> "$GITHUB_OUTPUT"
echo "Docs/metadata-only change — skipping the Android build."
fi
- name: Setup Java - name: Setup Java
if: steps.scope.outputs.code == 'true'
uses: actions/setup-java@v4 uses: actions/setup-java@v4
with: with:
distribution: 'zulu' distribution: 'zulu'
java-version: '17' java-version: '17'
- name: Setup Android SDK - name: Setup Android SDK
if: steps.scope.outputs.code == 'true'
uses: android-actions/setup-android@v3 uses: android-actions/setup-android@v3
with: with:
# Default ("tools platform-tools") drags in the Android Emulator # Default ("tools platform-tools") drags in the Android Emulator
@@ -36,12 +81,14 @@ jobs:
packages: '' packages: ''
- name: Setup Android SDK cache - name: Setup Android SDK cache
if: steps.scope.outputs.code == 'true'
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: /opt/android-sdk path: /opt/android-sdk
key: ${{ runner.os }}-android-sdk-37-36.0.0 key: ${{ runner.os }}-android-sdk-37-36.0.0
- name: Install Android SDK packages - name: Install Android SDK packages
if: steps.scope.outputs.code == 'true'
run: | run: |
yes | sdkmanager --licenses >/dev/null || true yes | sdkmanager --licenses >/dev/null || true
sdkmanager \ sdkmanager \
@@ -50,6 +97,7 @@ jobs:
"build-tools;36.0.0" "build-tools;36.0.0"
- name: Setup Gradle cache - name: Setup Gradle cache
if: steps.scope.outputs.code == 'true'
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: | path: |
@@ -60,21 +108,25 @@ jobs:
${{ runner.os }}-gradle- ${{ runner.os }}-gradle-
- name: Grant execute permission for gradlew - name: Grant execute permission for gradlew
if: steps.scope.outputs.code == 'true'
run: chmod +x ./gradlew run: chmod +x ./gradlew
# No --no-daemon: the daemon lives only as long as this job container # No --no-daemon: the daemon lives only as long as this job container
# and lets the following steps skip JVM startup + reconfiguration. # and lets the following steps skip JVM startup + reconfiguration.
- name: Lint (debug variant only) - name: Lint (debug variant only)
if: steps.scope.outputs.code == 'true'
run: ./gradlew lintDebug run: ./gradlew lintDebug
- name: Unit tests - name: Unit tests
if: steps.scope.outputs.code == 'true'
run: ./gradlew testDebugUnitTest run: ./gradlew testDebugUnitTest
- name: Assemble debug APK - name: Assemble debug APK
if: steps.scope.outputs.code == 'true'
run: ./gradlew assembleDebug run: ./gradlew assembleDebug
- name: Trivy filesystem scan - name: Trivy filesystem scan
if: github.ref == 'refs/heads/main' if: steps.scope.outputs.code == 'true'
run: | run: |
set -e set -e
SUDO="" SUDO=""

View File

@@ -1,78 +1,99 @@
name: Release — F-Droid repo + Gitea release name: Release — F-Droid repo + Gitea/Codeberg release
# A release is cut by merging a release branch into main with a bumped
# versionName (see docs/RELEASING.md). This workflow reads that versionName and,
# if no matching tag exists yet, runs tests, builds + signs the APK, publishes
# it to the F-Droid repo, creates the vX.Y.Z tag + Gitea release, and mirrors
# that release to Codeberg with the signed APK + a SHA-256 checksum as a
# direct-download channel — the tag is an output of the pipeline, not its
# trigger. Ordinary merges (no version bump) fall through `detect` and do
# nothing.
#
# A manual workflow_dispatch (from a branch) runs the re-sign-only recovery
# path: it re-signs the existing F-Droid index with the repo key and re-uploads,
# without building an APK or creating a release. Used for key rotation / repo
# recovery.
on: on:
push: push:
tags: branches: [main]
- '*'
workflow_dispatch: workflow_dispatch:
concurrency:
group: release
cancel-in-progress: false
jobs: jobs:
ci: # Cheap gate: resolve the version from the committed build.gradle and decide
# whether this push actually cuts a new release (no tag for it yet). Keeps the
# heavy job from running on every merge to main.
detect:
runs-on: docker runs-on: docker
env: outputs:
ANDROID_HOME: /opt/android-sdk is_release: ${{ steps.v.outputs.is_release }}
ANDROID_SDK_ROOT: /opt/android-sdk version: ${{ steps.v.outputs.version }}
version_code: ${{ steps.v.outputs.version_code }}
prerelease: ${{ steps.v.outputs.prerelease }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup Java
uses: actions/setup-java@v4
with: with:
distribution: 'zulu' submodules: recursive
java-version: '17'
- name: Setup Android SDK - name: Resolve version and whether it is a new release
uses: android-actions/setup-android@v3 id: v
with: env:
packages: '' TOKEN: ${{ secrets.GITHUB_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
- name: Setup Android SDK cache
uses: actions/cache@v4
with:
path: /opt/android-sdk
key: ${{ runner.os }}-android-sdk-37-36.0.0
- name: Install Android SDK packages
run: | run: |
yes | sdkmanager --licenses >/dev/null || true set -e
sdkmanager \ VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts)
"platform-tools" \ if [ -z "$VERSION" ]; then echo "No versionName in app/build.gradle.kts" >&2; exit 1; fi
"platforms;android-37.0" \ MAJOR=$(echo "$VERSION" | cut -d. -f1); MINOR=$(echo "$VERSION" | cut -d. -f2); PATCH=$(echo "$VERSION" | cut -d. -f3)
"build-tools;36.0.0" MAJOR=${MAJOR:-0}; MINOR=${MINOR:-0}; PATCH=${PATCH:-0}
VERSION_CODE=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "version_code=$VERSION_CODE" >> "$GITHUB_OUTPUT"
# Pre-1.0 is not stable yet: mark the Gitea release as a pre-release
# while MAJOR is 0. Graduates to a stable release automatically at 1.0.0.
if [ "$MAJOR" = "0" ]; then PRERELEASE=true; else PRERELEASE=false; fi
echo "prerelease=$PRERELEASE" >> "$GITHUB_OUTPUT"
echo "Resolved version $VERSION (code $VERSION_CODE, prerelease=$PRERELEASE)"
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "Manual dispatch — re-sign path, not a release."
echo "is_release=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# A tag for this version already existing means the release shipped on
# an earlier push; do nothing. Absent => this merge cuts the release.
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: token $TOKEN" "$API/git/refs/tags/v$VERSION")
if [ "$STATUS" = "200" ]; then
echo "Tag v$VERSION already exists — nothing to release."
echo "is_release=false" >> "$GITHUB_OUTPUT"
else
echo "No tag for v$VERSION yet — cutting the release."
echo "is_release=true" >> "$GITHUB_OUTPUT"
fi
- name: Setup Gradle cache # Releases: build + sign + publish, then mint the tag and Gitea release.
uses: actions/cache@v4 # Also runs on manual dispatch, where it skips the build and just re-signs and
with: # re-uploads the existing index (recovery path).
path: | release:
~/.gradle/caches needs: detect
~/.gradle/wrapper if: needs.detect.outputs.is_release == 'true' || github.event_name == 'workflow_dispatch'
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', 'gradle/libs.versions.toml') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Grant execute permission for gradlew
run: chmod +x ./gradlew
# Lint already enforced on every push to main via ci.yaml.
# Release sanity only re-runs tests + a debug build to catch
# any tag-resolved drift (e.g. version code substitution issues).
- name: Unit tests
run: ./gradlew testDebugUnitTest
- name: Assemble debug APK (sanity)
run: ./gradlew assembleDebug
build-and-deploy:
needs: ci
runs-on: docker runs-on: docker
env: env:
ANDROID_HOME: /opt/android-sdk ANDROID_HOME: /opt/android-sdk
ANDROID_SDK_ROOT: /opt/android-sdk ANDROID_SDK_ROOT: /opt/android-sdk
VERSION: ${{ needs.detect.outputs.version }}
VERSION_CODE: ${{ needs.detect.outputs.version_code }}
IS_RELEASE: ${{ needs.detect.outputs.is_release }}
PRERELEASE: ${{ needs.detect.outputs.prerelease }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup Java - name: Setup Java
uses: actions/setup-java@v4 uses: actions/setup-java@v4
@@ -121,31 +142,26 @@ jobs:
$SUDO apk add --no-cache jq $SUDO apk add --no-cache jq
fi fi
# Tag-only build steps. On a manual workflow_dispatch (ref = a branch, - name: Grant execute permission for gradlew
# not a tag) these are skipped: the job then just re-signs the existing run: chmod +x ./gradlew
# index with the configured repo key and re-uploads — used for key
# rotation / repo recovery without publishing a new APK. # The committed versionName is the source of truth. Pin versionCode to the
- name: Set version from git tag # value derived from it so the published APK's code is always
if: startsWith(github.ref, 'refs/tags/') # MAJOR*10000 + MINOR*100 + PATCH even if the committed code was forgotten.
- name: Pin versionCode to versionName
if: env.IS_RELEASE == 'true'
run: | run: |
set -e set -e
RAW_TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
VERSION="${RAW_TAG#v}"
MAJOR=$(echo "$VERSION" | cut -d. -f1)
MINOR=$(echo "$VERSION" | cut -d. -f2)
PATCH=$(echo "$VERSION" | cut -d. -f3)
MAJOR=${MAJOR:-0}; MINOR=${MINOR:-0}; PATCH=${PATCH:-0}
VERSION_CODE=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
echo "Version: $VERSION, VersionCode: $VERSION_CODE"
sed -i "s/versionName = \".*\"/versionName = \"$VERSION\"/" app/build.gradle.kts
sed -i "s/versionCode = .*/versionCode = $VERSION_CODE/" app/build.gradle.kts sed -i "s/versionCode = .*/versionCode = $VERSION_CODE/" app/build.gradle.kts
grep -E 'versionName|versionCode' app/build.gradle.kts grep -E 'versionName|versionCode' app/build.gradle.kts
# Export for later steps (F-Droid changelog, mapping asset name).
echo "VERSION=$VERSION" >> "$GITHUB_ENV" # Test the exact commit being shipped (only on a real release).
echo "VERSION_CODE=$VERSION_CODE" >> "$GITHUB_ENV" - name: Unit tests
if: env.IS_RELEASE == 'true'
run: ./gradlew testDebugUnitTest
- name: Setup Android Keystore - name: Setup Android Keystore
if: startsWith(github.ref, 'refs/tags/') if: env.IS_RELEASE == 'true'
env: env:
KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }} KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
@@ -160,11 +176,8 @@ jobs:
storeFile=upload-keystore.jks storeFile=upload-keystore.jks
EOF EOF
- name: Grant execute permission for gradlew
run: chmod +x ./gradlew
- name: Build release APK - name: Build release APK
if: startsWith(github.ref, 'refs/tags/') if: env.IS_RELEASE == 'true'
run: ./gradlew assembleRelease run: ./gradlew assembleRelease
- name: Setup F-Droid Server Tools - name: Setup F-Droid Server Tools
@@ -202,8 +215,7 @@ jobs:
set -euo pipefail set -euo pipefail
# Fail loudly if the repo key is not configured. NEVER auto-generate # Fail loudly if the repo key is not configured. NEVER auto-generate
# one: a fresh key changes the repo fingerprint and breaks every # one: a fresh key changes the repo fingerprint and breaks every
# user's pinned repo. (Replaces the old `fdroid update --create-key` # user's pinned repo.
# path, which silently rotated the key on a wiped server.)
if [ -z "${FDROID_KEYSTORE_BASE64:-}" ] || [ -z "${FDROID_CONFIG_BASE64:-}" ]; then if [ -z "${FDROID_KEYSTORE_BASE64:-}" ] || [ -z "${FDROID_CONFIG_BASE64:-}" ]; then
echo "ERROR: FDROID_KEYSTORE_BASE64 / FDROID_CONFIG_BASE64 secrets are not set." >&2 echo "ERROR: FDROID_KEYSTORE_BASE64 / FDROID_CONFIG_BASE64 secrets are not set." >&2
echo "Refusing to continue — will not auto-generate a new repo key." >&2 echo "Refusing to continue — will not auto-generate a new repo key." >&2
@@ -216,42 +228,33 @@ jobs:
mkdir -p fdroid/repo/icons mkdir -p fdroid/repo/icons
- name: Copy new APK to repo - name: Copy new APK to repo
if: startsWith(github.ref, 'refs/tags/') if: env.IS_RELEASE == 'true'
run: | run: |
set -e set -e
mkdir -p fdroid/repo mkdir -p fdroid/repo
REF_NAME="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}" cp app/build/outputs/apk/release/app-release.apk "fdroid/repo/agendula_v${VERSION}.apk"
SAFE_REF_NAME="$(echo "$REF_NAME" | tr '/ ' '__' | tr -cd '[:alnum:]_.-')"
if [ -z "$SAFE_REF_NAME" ]; then
SAFE_REF_NAME="${GITHUB_SHA:-manual}"
fi
cp app/build/outputs/apk/release/app-release.apk "fdroid/repo/floret_${SAFE_REF_NAME}.apk"
- name: Copy metadata to F-Droid repo # Per-version "What's New": ensure this version's changelog exists in the
# fastlane tree (committed at release-cut time for the official repo; this
# regenerates it from CHANGELOG.md so the self-hosted repo never depends on
# the commit having happened). The transform below then carries it across.
- name: Ensure this version's changelog is in the fastlane tree
if: env.IS_RELEASE == 'true'
run: bash scripts/sync_changelog_to_fastlane.sh
- name: Build F-Droid metadata from fastlane (single source of truth)
run: | run: |
mkdir -p fdroid/metadata mkdir -p fdroid/metadata
cp -r fdroid-metadata/* fdroid/metadata/ # App-level control file (Categories/License/links) for the self-hosted
# repo's `fdroid update`.
# Per-version "What's New" for F-Droid clients: the tag's CHANGELOG cp fdroid-metadata/de.jeanlucmakiola.agendula.yml fdroid/metadata/
# section written to changelogs/<versionCode>.txt (same extraction as the # Localized text + graphics + per-version changelogs come from the SAME
# Gitea release notes). en-US only — F-Droid falls back to it for locales # fastlane tree the official F-Droid repo harvests from source,
# without their own changelog. fdroid update bakes this into the index. # transformed into the F-Droid repo "localized" layout. One source of
- name: Generate F-Droid changelog for this version # truth, both channels.
if: startsWith(github.ref, 'refs/tags/') bash scripts/fastlane_to_fdroid_localized.sh \
run: | fastlane/metadata/android \
set -e fdroid/metadata/de.jeanlucmakiola.agendula
awk -v ver="$VERSION" '
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
/^## \[/ { flag = 0 }
flag' CHANGELOG.md > /tmp/changelog.txt
sed -i -e '/./,$!d' /tmp/changelog.txt
if [ ! -s /tmp/changelog.txt ]; then
echo "See CHANGELOG.md for $VERSION." > /tmp/changelog.txt
fi
CL_DIR="fdroid/metadata/de.jeanlucmakiola.floret/en-US/changelogs"
mkdir -p "$CL_DIR"
cp /tmp/changelog.txt "$CL_DIR/${VERSION_CODE}.txt"
echo "Wrote $CL_DIR/${VERSION_CODE}.txt"
- name: Generate F-Droid Index - name: Generate F-Droid Index
run: | run: |
@@ -272,97 +275,47 @@ jobs:
SFTP SFTP
# Publish the signed repo/ plus metadata/ (descriptions, screenshots, # Publish the signed repo/ plus metadata/ (descriptions, screenshots,
# per-version changelogs) so changelog history survives across # per-version changelogs) so changelog history survives across
# releases. keystore.p12 and config.yml are NEVER uploaded, so they # releases. keystore.p12 and config.yml are NEVER uploaded.
# can't re-enter the web-served tree; nginx serves only repo/ anyway.
sshpass -p "$PASS" scp $SSH_OPTS -r fdroid/repo fdroid/metadata "$USER@$HOST:dev/fdroid/" sshpass -p "$PASS" scp $SSH_OPTS -r fdroid/repo fdroid/metadata "$USER@$HOST:dev/fdroid/"
# Archive the R8 mapping so user crash stacktraces stay deobfuscatable. # The APK is published and the index re-signed — now record the release.
# Attached to the Gitea release (it's not an APK, so it fits the # Creating it with target_commitish makes Gitea create the vX.Y.Z tag at
# no-binaries rule). Best-effort: never fail a release over it. # this commit, so the tag only ever marks a fully-shipped release (and a
- name: Attach R8 mapping to Gitea release # failure before here leaves no tag, so re-running the workflow retries).
if: startsWith(github.ref, 'refs/tags/') - name: Create tag + Gitea release
continue-on-error: true if: env.IS_RELEASE == 'true'
env: env:
TOKEN: ${{ secrets.GITHUB_TOKEN }} TOKEN: ${{ secrets.GITHUB_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
SHA: ${{ github.sha }}
run: | run: |
set -e set -e
MAP="app/build/outputs/mapping/release/mapping.txt" TAG="v$VERSION"
if [ ! -f "$MAP" ]; then echo "No mapping.txt (R8 off?) — skipping."; exit 0; fi # Notes = this version's CHANGELOG section.
TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
ASSET="mapping-${VERSION:-$TAG}.txt.gz"
gzip -c "$MAP" > "/tmp/$ASSET"
# The release is created by the gitea-release job; ensure it exists
# (idempotent) so this job doesn't race it to a 404.
ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty')
if [ -z "$ID" ]; then
ID=$(curl -s -X POST -H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\"}" \
"$API/releases" | jq -r '.id // empty')
fi
if [ -z "$ID" ]; then echo "Could not resolve release id — skipping."; exit 0; fi
# Replace any prior asset of the same name (re-run safe).
OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \
| jq -r --arg n "$ASSET" '.[] | select(.name==$n) | .id')
[ -n "$OLD" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$OLD" >/dev/null || true
curl -s -X POST -H "Authorization: token $TOKEN" \
-F "attachment=@/tmp/$ASSET" \
"$API/releases/$ID/assets?name=$ASSET" -o /dev/null -w "asset upload HTTP %{http_code}\n"
# A Gitea release per tag, carrying the tag's CHANGELOG section as its
# notes. Deliberately no APK assets — distribution stays with the F-Droid
# repo; the release is the human-readable record. Gated on the tests-only
# ci job (not the deploy) so notes appear even if the F-Droid upload has
# an infrastructure hiccup.
gitea-release:
needs: ci
if: startsWith(github.ref, 'refs/tags/')
runs-on: docker
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Extract changelog section for this tag
run: |
set -e
TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
VERSION="${TAG#v}"
# Everything between "## [<version>]" and the next "## [" heading.
awk -v ver="$VERSION" ' awk -v ver="$VERSION" '
$0 ~ "^## \\[" ver "\\]" { flag = 1; next } $0 ~ "^## \\[" ver "\\]" { flag = 1; next }
/^## \[/ { flag = 0 } /^## \[/ { flag = 0 }
flag' CHANGELOG.md > release-notes.md flag' CHANGELOG.md > release-notes.md
# Trim leading blank lines.
sed -i -e '/./,$!d' release-notes.md sed -i -e '/./,$!d' release-notes.md
if [ ! -s release-notes.md ]; then if [ ! -s release-notes.md ]; then
echo "_No changelog entry for ${VERSION} — see CHANGELOG.md._" > release-notes.md echo "_No changelog entry for ${VERSION} — see CHANGELOG.md._" > release-notes.md
fi fi
echo "--- release notes ---" python3 - "$TAG" "$SHA" "$PRERELEASE" <<'PY' > payload.json
cat release-notes.md
- name: Create Gitea release
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
run: |
set -e
TAG="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
python3 - "$TAG" <<'PY' > payload.json
import json, sys import json, sys
print(json.dumps({ print(json.dumps({
"tag_name": sys.argv[1], "tag_name": sys.argv[1],
"target_commitish": sys.argv[2],
"name": sys.argv[1], "name": sys.argv[1],
"body": open("release-notes.md").read(), "body": open("release-notes.md").read(),
"draft": False, "draft": False,
"prerelease": False, # Pre-1.0 releases are flagged as pre-releases (see detect job).
"prerelease": sys.argv[3] == "true",
})) }))
PY PY
# Upsert: the build-and-deploy job may have created a bare release # Upsert (re-run safe): PATCH if a release for the tag already exists,
# first (to attach the mapping asset), so PATCH the notes if it # else POST a new one (which also creates the tag at target_commitish).
# exists, otherwise POST a new one. Both paths are re-run safe.
curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" > existing.json curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" > existing.json
ID=$(python3 -c "import json,sys; d=json.load(open('existing.json')); print(d.get('id',''))" 2>/dev/null || true) ID=$(jq -r '.id // empty' existing.json 2>/dev/null || true)
if [ -n "$ID" ]; then if [ -n "$ID" ]; then
CODE=$(curl -s -o response.json -w '%{http_code}' -X PATCH \ CODE=$(curl -s -o response.json -w '%{http_code}' -X PATCH \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
@@ -376,6 +329,119 @@ jobs:
fi fi
cat response.json cat response.json
if [ "$CODE" != "$OK" ]; then if [ "$CODE" != "$OK" ]; then
echo "Release upsert failed with HTTP $CODE (expected $OK)" echo "Release upsert failed with HTTP $CODE (expected $OK)" >&2
exit 1 exit 1
fi fi
echo "Created/updated release $TAG at $SHA"
# Archive the R8 mapping so user crash stacktraces stay deobfuscatable.
# Attached to the release (it's not an APK, so it fits the no-binaries
# rule). Best-effort: never fail a release over it.
- name: Attach R8 mapping to Gitea release
if: env.IS_RELEASE == 'true'
continue-on-error: true
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
run: |
set -e
MAP="app/build/outputs/mapping/release/mapping.txt"
if [ ! -f "$MAP" ]; then echo "No mapping.txt (R8 off?) — skipping."; exit 0; fi
TAG="v$VERSION"
ASSET="mapping-${VERSION}.txt.gz"
gzip -c "$MAP" > "/tmp/$ASSET"
ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty')
if [ -z "$ID" ]; then echo "Could not resolve release id — skipping."; exit 0; fi
# Replace any prior asset of the same name (re-run safe).
OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \
| jq -r --arg n "$ASSET" '.[] | select(.name==$n) | .id')
[ -n "$OLD" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$OLD" >/dev/null || true
curl -s -X POST -H "Authorization: token $TOKEN" \
-F "attachment=@/tmp/$ASSET" \
"$API/releases/$ID/assets?name=$ASSET" -o /dev/null -w "asset upload HTTP %{http_code}\n"
# Mirror the release to the Codeberg mirror as a direct-download channel
# for users who don't want F-Droid. Gitea already push-mirrors branches +
# tags to Codeberg, but releases aren't git objects so they don't sync —
# we create the release there over the API and attach the signed APK plus
# a SHA-256 checksum. The APK is identical to the F-Droid one (same app
# key), so this adds no trust surface. Best-effort: a Codeberg outage
# (it 504s under load) must never fail an already-published F-Droid
# release. Needs the CODEBERG_RELEASE_TOKEN secret; skips cleanly if unset.
- name: Publish release to Codeberg
if: env.IS_RELEASE == 'true'
continue-on-error: true
env:
TOKEN: ${{ secrets.CODEBERG_RELEASE_TOKEN }}
API: https://codeberg.org/api/v1/repos/jlmakiola/agendula
SHA: ${{ github.sha }}
run: |
set -e
if [ -z "${TOKEN:-}" ]; then
echo "CODEBERG_RELEASE_TOKEN not set — skipping Codeberg publish."
exit 0
fi
TAG="v$VERSION"
APK="app/build/outputs/apk/release/app-release.apk"
if [ ! -f "$APK" ]; then echo "No release APK found — skipping." >&2; exit 1; fi
ASSET_APK="agendula_v${VERSION}.apk"
ASSET_SUM="${ASSET_APK}.sha256"
cp "$APK" "/tmp/$ASSET_APK"
( cd /tmp && sha256sum "$ASSET_APK" > "$ASSET_SUM" )
# Release notes: reuse the section extracted for the Gitea release,
# fall back to the CHANGELOG entry if that step's file is gone.
if [ ! -s release-notes.md ]; then
awk -v ver="$VERSION" '
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
/^## \[/ { flag = 0 }
flag' CHANGELOG.md > release-notes.md
sed -i -e '/./,$!d' release-notes.md
fi
[ -s release-notes.md ] || echo "_See CHANGELOG.md for ${VERSION}._" > release-notes.md
# The pipeline creates the tag via the Gitea API, which the push mirror
# (sync_on_commit only fires on real git pushes) doesn't propagate
# promptly — so a release POST that carries a target_commitish can
# outrun the mirror and 500 on a commit/tag Codeberg hasn't received.
# Push the tag straight to Codeberg so it's guaranteed present, then
# attach the release to that existing tag with NO target_commitish
# (which is what triggered the 500).
git tag -f "$TAG" "$SHA"
git push -f "https://jlmakiola:${TOKEN}@codeberg.org/jlmakiola/agendula.git" \
"refs/tags/$TAG"
python3 - "$TAG" "$PRERELEASE" <<'PY' > cb-payload.json
import json, sys
tag, pre = sys.argv[1:3]
print(json.dumps({
"tag_name": tag,
"name": tag,
"body": open("release-notes.md").read(),
"draft": False,
# Pre-1.0 releases are flagged as pre-releases (see detect job).
"prerelease": pre == "true",
}))
PY
# Upsert (re-run safe).
ID=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | jq -r '.id // empty')
if [ -n "$ID" ]; then
curl -s -o /dev/null -w "release PATCH HTTP %{http_code}\n" -X PATCH \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @cb-payload.json "$API/releases/$ID"
else
curl -s -o cb-response.json -w "release POST HTTP %{http_code}\n" -X POST \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @cb-payload.json "$API/releases"
ID=$(jq -r '.id // empty' cb-response.json 2>/dev/null || true)
fi
if [ -z "$ID" ]; then echo "Could not resolve Codeberg release id." >&2; exit 1; fi
# Attach APK + checksum, replacing any prior asset of the same name.
for A in "$ASSET_APK" "$ASSET_SUM"; do
OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \
| jq -r --arg n "$A" '.[] | select(.name==$n) | .id')
[ -n "$OLD" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$OLD" >/dev/null || true
curl -s -X POST -H "Authorization: token $TOKEN" \
-F "attachment=@/tmp/$A" \
"$API/releases/$ID/assets?name=$A" -o /dev/null -w "asset $A HTTP %{http_code}\n"
done
echo "Published $TAG to Codeberg."

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "floret-kit"]
path = floret-kit
url = https://gitea.jeanlucmakiola.de/makiolaj/floret-kit.git

View File

@@ -1,11 +1,81 @@
# Changelog # Changelog
All notable changes to this project are documented here. The format follows All notable changes to this project are documented here. The format follows
[Keep a Changelog](https://keepachangelog.com/); the latest released git tag is [Keep a Changelog](https://keepachangelog.com/); the `versionName` committed in
the source of truth for version codes (see Calendula's `docs/RELEASING.md`). `app/build.gradle.kts` is the source of truth for a release (see
`docs/RELEASING.md`), and the `vX.Y.Z` tag is minted by the pipeline.
## [Unreleased] ## [Unreleased]
## [0.3.1] - 2026-07-20
### Fixed
- Agendula no longer crashes on launch. Every 0.3.0 install was affected: the
release build stripped a constructor that the background-work scheduler needs
to open its database, and that happens before the app draws anything.
## [0.3.0] - 2026-07-19
### Added
- Reminders: Agendula now delivers your due reminders itself. A one-time setup
step explains this and asks for notification access, and a master switch in
Settings turns the whole thing off again.
- A Settings screen, from the gear on the overview: appearance and theme, which
fields the task form shows, your default list, and reminder defaults.
- The overview leads with Today — a progress ring showing how much of today
you've finished — followed by a live preview of what's coming up next.
- Search across every task, open or completed, from the top bar.
- A proper app icon.
### Changed
- A tidier top bar: no app title, with search and settings pinned to the right.
## [0.2.2] - 2026-07-19
### Fixed
- Release automation now reliably mirrors each release to the Codeberg mirror
(signed APK + SHA-256 checksum). The 0.2.1 attempt failed when the release tag
had already been synced to Codeberg.
## [0.2.1] - 2026-07-19
### Added
- Releases are now also published to the Codeberg mirror as a direct download:
each release carries the signed APK plus a SHA-256 checksum, for users who
don't use F-Droid.
## [0.2.0] - 2026-06-27
### Added
- M2 Material 3 Expressive UI — the app is fully navigable now: the
provider/permission onboarding gate, the lists overview (smart lists + user
lists grouped by account), and the task list (swipe-to-complete / -delete,
inline add, smart-list section headers), detail, and create/edit screens, all
wired to the M1 ViewModels.
- M3 detail/edit polish: a "Progress" slider (percent-complete, 5% detents,
written to `Tasks.PERCENT_COMPLETE`) and conflict-safe saves — `updateTask`
re-checks the provider's `last_modified` against the value captured when the
form loaded and surfaces an overwrite-or-cancel prompt instead of clobbering an
external change (e.g. a DAVx5 sync).
- M4 subtasks (UI): reparent — a full-width, searchable "Parent task" sheet on
the edit form, with candidates grouped by due-date section, files a task under
any active top-level task in its list (or "None" to promote it); switching list
clears the now-invalid parent. Tapping a subtask in the detail screen opens its
own detail, which shows a "Part of …" parent card. On the task list a parent has
a dedicated expand button that reveals its children as a nested grouped run,
ending with an inline "add a subtask" row (an opt-out toggle is planned for the
M6 settings screen).
- Priority is coloured by level (green / amber / red pastels) on the list, detail,
and edit screens.
### Fixed
- Overview open-counts now count top-level tasks only, so subtasks — and
especially open subtasks under a completed parent — no longer inflate a list's
"N open" or the smart-list counts.
- Swipe-to-delete now reveals its red background + icon as you drag (tracking the
live direction, not just the settled target), and a floating "Deleted · Undo"
chip defers the actual delete so it can be restored.
## [0.1.0] - 2026-06-18 ## [0.1.0] - 2026-06-18
### Added ### Added

View File

@@ -1,6 +1,6 @@
# Contributing to Floret # Contributing to Agendula
Thanks for your interest in Floret — a Material 3 Expressive task app that's a Thanks for your interest in Agendula — a Material 3 Expressive task app that's a
pure front-end over the OpenTasks `TaskContract` provider, with no own database pure front-end over the OpenTasks `TaskContract` provider, with no own database
or sync stack. Before diving in, skim [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) or sync stack. Before diving in, skim [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)
(how it's built), [`docs/ROADMAP.md`](docs/ROADMAP.md) (what's next), and (how it's built), [`docs/ROADMAP.md`](docs/ROADMAP.md) (what's next), and
@@ -30,11 +30,12 @@ would expose provider details to a ViewModel or the UI, it's in the wrong layer.
```sh ```sh
./gradlew :app:assembleDebug # build the debug APK ./gradlew :app:assembleDebug # build the debug APK
./gradlew :app:testDebugUnitTest # JVM unit tests (JUnit5 + Truth + Turbine) ./gradlew :app:testDebugUnitTest # JVM unit tests (JUnit5 + Truth + Turbine)
./gradlew lintDebug # Android lint (CI runs this on every push) ./gradlew lintDebug # Android lint (CI runs this on every PR)
``` ```
CI (`.gitea/workflows/ci.yaml`) runs lint → unit tests → debug build on every CI (`.gitea/workflows/ci.yaml`) runs a reproducible-release invariant check,
push, so run these locally before opening a PR. Keep CI green. then lint → unit tests → debug build on every pull request, so run these locally
before opening a PR. Keep CI green.
## Where to put code ## Where to put code
@@ -75,12 +76,14 @@ push, so run these locally before opening a PR. Keep CI green.
- If your change shifts the architecture or completes a milestone, update - If your change shifts the architecture or completes a milestone, update
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) / [`docs/ROADMAP.md`](docs/ROADMAP.md) [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) / [`docs/ROADMAP.md`](docs/ROADMAP.md)
in the same PR. in the same PR.
- Don't bump `versionName` / `versionCode` by hand — the git tag drives those at - Don't bump `versionName` / `versionCode` in a regular PR — the committed
release time. `versionName` is bumped only when **cutting a release** (that bump reaching
`main` is what triggers the release; the pipeline then mints the tag). See
[`docs/RELEASING.md`](docs/RELEASING.md).
## Scope ## Scope
Floret stays true to its thesis: a front-end over **open** task backends Agendula stays true to its thesis: a front-end over **open** task backends
(CalDAV / iCalendar / DecSync via the OpenTasks provider). Proprietary backends (CalDAV / iCalendar / DecSync via the OpenTasks provider). Proprietary backends
(Google Tasks, Microsoft To Do) are out of scope by design — they'd mean owning (Google Tasks, Microsoft To Do) are out of scope by design — they'd mean owning
a sync stack. v1 targets the OpenTasks contract (OpenTasks + tasks.org); jtx's a sync stack. v1 targets the OpenTasks contract (OpenTasks + tasks.org); jtx's

View File

@@ -1,6 +1,6 @@
<div align="center"> <div align="center">
<h1>Floret</h1> <h1>Agendula</h1>
<p><strong>A modern Material 3 Expressive task app for Android.</strong><br> <p><strong>A modern Material 3 Expressive task app for Android.</strong><br>
Reads, writes, and reminds — on top of an existing tasks provider, with no own Reads, writes, and reminds — on top of an existing tasks provider, with no own
@@ -13,14 +13,16 @@ sync stack.</p>
</div> </div>
Floret is the task-list sibling to [Calendula](https://gitea.jeanlucmakiola.de/makiolaj/calendula). Agendula is the task-list sibling to [Calendula](https://gitea.jeanlucmakiola.de/makiolaj/calendula).
Where Calendula is a pure front-end over Android's `CalendarContract`, Floret is Where Calendula is a pure front-end over Android's `CalendarContract`, Agendula is
a pure front-end over the **OpenTasks `TaskContract` provider** — the store that a pure front-end over the **OpenTasks `TaskContract` provider** — the store that
DAVx5 (and SmoothSync, DecSync, …) syncs your CalDAV `VTODO` tasks into. No own DAVx5 (and SmoothSync, DecSync, …) syncs your CalDAV `VTODO` tasks into. No own
database, no reinvented sync. database, no reinvented sync.
A Calendula flower head is botanically made of many small *florets* — the The name rhymes with its sibling on purpose: **Agendula** is *agenda* — Latin for
individual items that make up the bloom. Floret is those items: your tasks. “things to be done” — given Calendula's `-ula` ending. Calendula keeps your days;
Agendula keeps your to-dos. (A Calendula flower head is botanically a cluster of
many small *florets* — so the two apps are florets of one bloom.)
> **Status: data layer done, UI in progress.** The full non-visual stack over > **Status: data layer done, UI in progress.** The full non-visual stack over
> the `TaskContract` provider — provider resolution, live-updating reads, > the `TaskContract` provider — provider resolution, live-updating reads,
@@ -32,7 +34,7 @@ individual items that make up the bloom. Floret is those items: your tasks.
## Sync sources (by design) ## Sync sources (by design)
Floret works with anything that writes to the tasks provider — **DAVx5** Agendula works with anything that writes to the tasks provider — **DAVx5**
(CalDAV), **SmoothSync**, **CalDAV-Sync**, **DecSync CC**, or any Android sync (CalDAV), **SmoothSync**, **CalDAV-Sync**, **DecSync CC**, or any Android sync
adapter — because it builds on the provider, not on any one sync app. Google adapter — because it builds on the provider, not on any one sync app. Google
Tasks / Microsoft To Do are out of scope by design (proprietary; they would mean Tasks / Microsoft To Do are out of scope by design (proprietary; they would mean

View File

@@ -16,20 +16,21 @@ val keystoreProperties = Properties().apply {
} }
android { android {
namespace = "de.jeanlucmakiola.floret" namespace = "de.jeanlucmakiola.agendula"
compileSdk = 37 compileSdk = 37
defaultConfig { defaultConfig {
applicationId = "de.jeanlucmakiola.floret" applicationId = "de.jeanlucmakiola.agendula"
minSdk = 29 minSdk = 29
targetSdk = 36 targetSdk = 36
// The git tag is the single source of truth for released builds: at // These committed values ARE the source of truth for a release: merging
// release time .gitea/workflows/release.yaml derives both fields from // a bumped versionName into main triggers .gitea/workflows/release.yaml,
// the tag, with versionCode = MAJOR*10000 + MINOR*100 + PATCH // which builds this version and then creates the matching vX.Y.Z tag +
// (e.g. v2.0.0 -> 20000). These committed values are the dev/local // release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
// default; keep them matching the latest released tag. See docs/RELEASING.md. // PATCH from versionName, e.g. 0.2.0 -> 200). The Gitea release is marked
versionCode = 100 // as a pre-release while MAJOR is 0. See docs/RELEASING.md.
versionName = "0.1.0" versionCode = 301
versionName = "0.3.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -47,6 +48,11 @@ android {
buildTypes { buildTypes {
release { release {
// Keep release builds reproducible for F-Droid: don't let AGP embed
// build-environment git metadata (META-INF/version-control-info.textproto),
// whose `revision`/path content varies by build machine and is the only
// thing that otherwise differs from a clean from-source rebuild.
vcsInfo { include = false }
isMinifyEnabled = true isMinifyEnabled = true
isShrinkResources = true isShrinkResources = true
proguardFiles( proguardFiles(
@@ -61,6 +67,22 @@ android {
applicationIdSuffix = ".debug" applicationIdSuffix = ".debug"
isMinifyEnabled = false isMinifyEnabled = false
} }
// A locally-installable twin of `release`: same R8 shrinking + obfuscation
// and resource shrinking, but debug-signed and given its own applicationId
// suffix so it installs alongside both the production app (signed with the
// real key) and the debug build. Used to smoke-test a release candidate on
// a real device before merging to main — R8-only breakage and first-run/
// permission states don't surface in the unminified debug build, nor on a
// device that already holds the permission. Never published. See
// docs/RELEASING.md.
create("releaseTest") {
initWith(getByName("release"))
applicationIdSuffix = ".releasetest"
signingConfig = signingConfigs.getByName("debug")
isMinifyEnabled = true
isShrinkResources = true
matchingFallbacks += "release"
}
} }
compileOptions { compileOptions {
@@ -73,6 +95,16 @@ android {
buildConfig = true buildConfig = true
} }
// Don't embed AGP's dependency-metadata block in the APK signing block. It's
// a Play-oriented blob, and F-Droid's reproducible-build scanner rejects any
// "extra signing block" — so leaving it in blocks publishing to the official
// repo. It lives in the signing block, not the zip entries, so disabling it
// doesn't change the build output (reproducibility is unaffected).
dependenciesInfo {
includeInApk = false
includeInBundle = false
}
packaging { packaging {
resources { resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}" excludes += "/META-INF/{AL2.0,LGPL2.1}"
@@ -110,6 +142,7 @@ dependencies {
implementation(libs.hilt.android) implementation(libs.hilt.android)
implementation(libs.androidx.hilt.navigation.compose) implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.androidx.navigation.compose)
ksp(libs.hilt.compiler) ksp(libs.hilt.compiler)
implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.datastore.preferences)
@@ -119,6 +152,12 @@ dependencies {
implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.core)
implementation("de.jeanlucmakiola.floret:core-time")
implementation("de.jeanlucmakiola.floret:core-reminders")
implementation("de.jeanlucmakiola.floret:core-locale")
implementation("de.jeanlucmakiola.floret:core-crash")
implementation("de.jeanlucmakiola.floret:identity")
implementation("de.jeanlucmakiola.floret:components")
debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest) debugImplementation(libs.androidx.ui.test.manifest)

View File

@@ -2,5 +2,20 @@
-keep class dagger.hilt.** { *; } -keep class dagger.hilt.** { *; }
-keep @dagger.hilt.android.HiltAndroidApp class * -keep @dagger.hilt.android.HiltAndroidApp class *
# Room instantiates its generated <Database>_Impl reflectively through a no-arg
# constructor. R8 under AGP 9 keeps the class but prunes that constructor, since
# nothing calls it directly Room then throws InstantiationException, reported
# as "Failed to create an instance of ...". We pull Room in transitively via
# Glance -> WorkManager, whose WorkDatabase is built by WorkManagerInitializer
# at startup, so the app died on launch in every minified build (issue #1).
-keep class * extends androidx.room.RoomDatabase { <init>(); }
# WorkManager likewise looks its workers up by name and calls this constructor
# reflectively same pruning, but it only bites once a worker actually runs
# (Glance's widget updates), so keep it explicitly rather than wait for it.
-keep class * extends androidx.work.ListenableWorker {
<init>(android.content.Context, androidx.work.WorkerParameters);
}
# Compose Compiler may keep its own; defaults are fine # Compose Compiler may keep its own; defaults are fine
-dontwarn org.jetbrains.annotations.** -dontwarn org.jetbrains.annotations.**

View File

@@ -30,15 +30,16 @@
</queries> </queries>
<application <application
android:name=".FloretApp" android:name=".AgendulaApp"
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:localeConfig="@xml/locales_config"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.Floret" android:theme="@style/Theme.Agendula"
tools:targetApi="35"> tools:targetApi="35">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
@@ -51,6 +52,14 @@
</intent-filter> </intent-filter>
</activity> </activity>
<!-- Standalone crash-report surface; MainActivity routes here on a
startup crash-loop. Not exported, kept out of recents. -->
<activity
android:name=".ui.crash.CrashReportActivity"
android:exported="false"
android:excludeFromRecents="true"
android:launchMode="singleTask" />
<!-- Reminder alarm fires here (internal PendingIntent → not exported). --> <!-- Reminder alarm fires here (internal PendingIntent → not exported). -->
<receiver <receiver
android:name=".data.reminders.DueReminderReceiver" android:name=".data.reminders.DueReminderReceiver"
@@ -76,6 +85,17 @@
<data android:scheme="content" android:host="org.dmfs.tasks" /> <data android:scheme="content" android:host="org.dmfs.tasks" />
</intent-filter> </intent-filter>
</receiver> </receiver>
<!-- Persists the per-app language on API < 33, where the platform
per-app-languages API is unavailable. On 33+ this is a no-op. -->
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
android:enabled="false"
android:exported="false">
<meta-data
android:name="autoStoreLocales"
android:value="true" />
</service>
</application> </application>
</manifest> </manifest>

View File

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.floret package de.jeanlucmakiola.agendula
import android.app.Application import android.app.Application
import dagger.hilt.EntryPoint import dagger.hilt.EntryPoint
@@ -6,22 +6,35 @@ import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.floret.data.reminders.ReminderScheduler import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
import de.jeanlucmakiola.floret.crash.CrashConfig
import de.jeanlucmakiola.floret.crash.CrashReporter
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** /**
* Application entry point. Registered as android:name=".FloretApp". Besides * Application entry point. Registered as android:name=".AgendulaApp". Besides
* Hilt init, it kicks off a reminder re-sync on launch (independent of any UI), * Hilt init, it kicks off a reminder re-sync on launch (independent of any UI),
* so alarms reflect tasks synced while the app was closed. * so alarms reflect tasks synced while the app was closed.
*/ */
@HiltAndroidApp @HiltAndroidApp
class FloretApp : Application() { class AgendulaApp : Application() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
// Install first thing so startup crashes are captured too (privacy-
// respecting, on-device; the user submits the report by hand).
CrashReporter.install(
this,
CrashConfig(
appLabel = getString(R.string.app_name),
newIssueUrl = getString(R.string.report_issue_url),
chooseIssueUrl = getString(R.string.report_issue_url),
issueTitle = getString(R.string.crash_report_issue_title),
),
)
val scheduler = EntryPointAccessors val scheduler = EntryPointAccessors
.fromApplication(this, ReminderEntryPoint::class.java) .fromApplication(this, ReminderEntryPoint::class.java)
.reminderScheduler() .reminderScheduler()

View File

@@ -0,0 +1,114 @@
package de.jeanlucmakiola.agendula
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.agendula.data.demo.DemoSeeder
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
import de.jeanlucmakiola.agendula.ui.RootScreen
import de.jeanlucmakiola.agendula.ui.crash.CrashReportActivity
import de.jeanlucmakiola.agendula.ui.settings.SettingsViewModel
import de.jeanlucmakiola.agendula.ui.theme.AgendulaTheme
import de.jeanlucmakiola.floret.crash.CrashReportDialog
import de.jeanlucmakiola.floret.crash.CrashReporter
import de.jeanlucmakiola.floret.crash.submitCrashReport
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Single activity. The theme follows [SettingsViewModel]; [RootScreen] is the
* (replaceable) functional scaffold over the real data layer. Task-detail intent
* routing for reminder taps lands with the full UI.
*/
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@Inject lateinit var demoSeeder: DemoSeeder
// A captured crash report awaiting the user's decision, surfaced as a dialog
// over the app on the next launch (the single-crash path). A startup
// crash-loop is handled out of band, before setContent — see below.
private var pendingCrashReport by mutableStateOf<String?>(null)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// If the app keeps crashing as it starts, the main UI can't be trusted
// to come up. Route to the standalone report screen instead of
// re-entering the crashing graph.
if (CrashReporter.isCrashLoop(this)) {
startActivity(Intent(this, CrashReportActivity::class.java))
finish()
return
}
enableEdgeToEdge()
// Surface a single captured crash as a dialog on the next launch.
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
// Debug-only sample data: `am start ... --ez agendula_seed true`. Seeds a
// local (non-syncing) demo list once; no-op without the extra.
if (BuildConfig.DEBUG && intent.getBooleanExtra(EXTRA_SEED, false)) {
lifecycleScope.launch { runCatching { demoSeeder.seed() } }
}
setContent {
val settingsViewModel: SettingsViewModel = hiltViewModel()
val ui by settingsViewModel.state.collectAsStateWithLifecycle()
val darkTheme = when (ui.settings.themeMode) {
ThemeMode.SYSTEM -> isSystemInDarkTheme()
ThemeMode.LIGHT -> false
ThemeMode.DARK -> true
}
AgendulaTheme(darkTheme = darkTheme, dynamicColor = ui.settings.dynamicColor) {
RootScreen(modifier = Modifier.fillMaxSize())
pendingCrashReport?.let { report ->
CrashReportDialog(
report = report,
onSend = {
submitCrashReport(this@MainActivity, report)
CrashReporter.clearReport(this@MainActivity)
pendingCrashReport = null
},
onDismiss = {
CrashReporter.dismissPrompt(this@MainActivity)
pendingCrashReport = null
},
)
}
}
}
}
override fun onResume() {
super.onResume()
// A successful start breaks any loop; reset the timing trail so a later
// ordinary crash isn't mistaken for a loop.
CrashReporter.markHealthy(this)
}
companion object {
const val EXTRA_TASK_ID = "de.jeanlucmakiola.agendula.extra.TASK_ID"
private const val EXTRA_SEED = "agendula_seed"
/** Opens the app focused on a task (reminder taps). Routing lands with the UI. */
fun taskIntent(context: Context, taskId: Long): Intent =
Intent(context, MainActivity::class.java).apply {
putExtra(EXTRA_TASK_ID, taskId)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
}
}

View File

@@ -1,10 +1,10 @@
package de.jeanlucmakiola.floret.data.demo package de.jeanlucmakiola.agendula.data.demo
import de.jeanlucmakiola.floret.data.di.IoDispatcher import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.floret.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.floret.domain.DayWindow import de.jeanlucmakiola.floret.time.DayWindow
import de.jeanlucmakiola.floret.domain.Priority import de.jeanlucmakiola.agendula.domain.Priority
import de.jeanlucmakiola.floret.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskForm
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -16,10 +16,10 @@ import kotlin.time.Instant
/** /**
* Debug-only one-shot sample data. Creates a **local, device-only** list * Debug-only one-shot sample data. Creates a **local, device-only** list
* ("Floret Demo") so nothing syncs to a CalDAV server and fills it with * ("Agendula Demo") so nothing syncs to a CalDAV server and fills it with
* tasks spread across overdue / today / upcoming / no-date / completed so every * tasks spread across overdue / today / upcoming / no-date / completed so every
* smart list shows content. Idempotent: skips if the demo list already exists. * smart list shows content. Idempotent: skips if the demo list already exists.
* Triggered from [de.jeanlucmakiola.floret.MainActivity] only in debug builds. * Triggered from [de.jeanlucmakiola.agendula.MainActivity] only in debug builds.
*/ */
@Singleton @Singleton
class DemoSeeder @Inject constructor( class DemoSeeder @Inject constructor(
@@ -47,14 +47,14 @@ class DemoSeeder @Inject constructor(
repository.createTask(TaskForm(title = "Gather receipts", listId = listId, parentId = invoiceId)) repository.createTask(TaskForm(title = "Gather receipts", listId = listId, parentId = invoiceId))
repository.createTask(TaskForm(title = "Book train tickets", listId = listId, due = at(te + 6 * day), priority = Priority.LOW)) repository.createTask(TaskForm(title = "Book train tickets", listId = listId, due = at(te + 6 * day), priority = Priority.LOW))
repository.createTask(TaskForm(title = "Read Compose 1.5 release notes", listId = listId)) repository.createTask(TaskForm(title = "Read Compose 1.5 release notes", listId = listId))
repository.createTask(TaskForm(title = "Sketch the Floret app icon", listId = listId)) repository.createTask(TaskForm(title = "Sketch the Agendula app icon", listId = listId))
val done = repository.createTask(TaskForm(title = "Renew domain name", listId = listId, due = at(ts - 2 * day))) val done = repository.createTask(TaskForm(title = "Renew domain name", listId = listId, due = at(ts - 2 * day)))
repository.setCompleted(done, completed = true) repository.setCompleted(done, completed = true)
} }
private companion object { private companion object {
const val DEMO_LIST = "Floret Demo" const val DEMO_LIST = "Agendula Demo"
const val DEMO_COLOR = 0xFF7A5C6B.toInt() const val DEMO_COLOR = 0xFF7A5C6B.toInt()
} }
} }

View File

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.floret.data.di package de.jeanlucmakiola.agendula.data.di
import android.content.Context import android.content.Context
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
@@ -10,16 +10,16 @@ import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.floret.data.tasks.AndroidTasksDataSource import de.jeanlucmakiola.agendula.data.tasks.AndroidTasksDataSource
import de.jeanlucmakiola.floret.data.tasks.TasksDataSource import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import de.jeanlucmakiola.floret.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.floret.data.tasks.TasksRepositoryImpl import de.jeanlucmakiola.agendula.data.tasks.TasksRepositoryImpl
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import javax.inject.Singleton import javax.inject.Singleton
private val Context.floretDataStore: DataStore<Preferences> by preferencesDataStore( private val Context.agendulaDataStore: DataStore<Preferences> by preferencesDataStore(
name = "floret_prefs", name = "agendula_prefs",
) )
@Module @Module
@@ -42,7 +42,7 @@ object DataProvideModule {
@Provides @Provides
@Singleton @Singleton
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> = fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
context.floretDataStore context.agendulaDataStore
@Provides @Provides
@IoDispatcher @IoDispatcher

View File

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.floret.data.di package de.jeanlucmakiola.agendula.data.di
import javax.inject.Qualifier import javax.inject.Qualifier

View File

@@ -0,0 +1,124 @@
package de.jeanlucmakiola.agendula.data.prefs
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import de.jeanlucmakiola.agendula.domain.TaskFormField
import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.floret.reminders.ReminderOverrideCodec
import de.jeanlucmakiola.floret.reminders.applyReminderOverride
import de.jeanlucmakiola.floret.reminders.reminderLeadFor
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
enum class ThemeMode { SYSTEM, LIGHT, DARK }
data class Settings(
val themeMode: ThemeMode = ThemeMode.SYSTEM,
val dynamicColor: Boolean = true,
/** The list a new task defaults to; `null` = first available. */
val defaultListId: Long? = null,
/** Default minutes before due to remind; 0 = at due time. */
val reminderLeadMinutes: Int = 0,
/** Master switch for due reminders; off clears every scheduled alarm. */
val remindersEnabled: Boolean = true,
/** Whether the inline "add a subtask" row shows on expanded task-list groups. */
val showAddSubtaskRow: Boolean = true,
/**
* Add affordance for a list: `false` = the floating "New task" button (opens
* the editor); `true` = a quick-add bar pinned to the bottom of a real list.
* Smart lists always use the button (they have no single list to add into).
*/
val bottomAddBar: Boolean = false,
/**
* Per-list overrides of [reminderLeadMinutes]: a list present in the map
* overrides the global default (a null value = no reminder); absent = inherit.
*/
val perListReminderOverride: Map<Long, Int?> = emptyMap(),
/** Optional edit-form fields shown by default; the rest sit behind "More fields". */
val defaultEditFields: Set<TaskFormField> = emptySet(),
) {
/** The lead time for a task in [listId]: its override if set, else the global default. */
fun reminderLeadFor(listId: Long): Int? =
perListReminderOverride.reminderLeadFor(listId, reminderLeadMinutes)
}
/** App preferences, backed by DataStore. Mirrors Calendula's prefs shape. */
@Singleton
class SettingsPrefs @Inject constructor(
private val dataStore: DataStore<Preferences>,
) {
val settings: Flow<Settings> = dataStore.data.map { p ->
Settings(
themeMode = p[THEME_MODE]?.let { runCatching { ThemeMode.valueOf(it) }.getOrNull() }
?: ThemeMode.SYSTEM,
dynamicColor = p[DYNAMIC_COLOR] ?: true,
defaultListId = p[DEFAULT_LIST_ID]?.takeIf { it > 0 },
reminderLeadMinutes = p[REMINDER_LEAD] ?: 0,
remindersEnabled = p[REMINDERS_ENABLED] ?: true,
showAddSubtaskRow = p[SHOW_ADD_SUBTASK_ROW] ?: true,
bottomAddBar = p[BOTTOM_ADD_BAR] ?: false,
perListReminderOverride = reminderCodec.parse(p[LIST_REMINDER_OVERRIDE]),
defaultEditFields = p[DEFAULT_EDIT_FIELDS].orEmpty()
.mapNotNull { name -> runCatching { TaskFormField.valueOf(name) }.getOrNull() }
.toSet(),
)
}
suspend fun setThemeMode(mode: ThemeMode) = dataStore.edit { it[THEME_MODE] = mode.name }
suspend fun setDynamicColor(enabled: Boolean) = dataStore.edit { it[DYNAMIC_COLOR] = enabled }
suspend fun setDefaultListId(id: Long?) = dataStore.edit {
if (id == null) it.remove(DEFAULT_LIST_ID) else it[DEFAULT_LIST_ID] = id
}
suspend fun setReminderLeadMinutes(minutes: Int) = dataStore.edit { it[REMINDER_LEAD] = minutes }
/** One-time reminder onboarding gate; false until the step has been shown. */
val reminderOnboardingDone: Flow<Boolean> = dataStore.data.map { it[REMINDER_ONBOARDING_DONE] ?: false }
suspend fun setReminderOnboardingDone() = dataStore.edit { it[REMINDER_ONBOARDING_DONE] = true }
suspend fun setRemindersEnabled(enabled: Boolean) = dataStore.edit { it[REMINDERS_ENABLED] = enabled }
suspend fun setShowAddSubtaskRow(show: Boolean) = dataStore.edit { it[SHOW_ADD_SUBTASK_ROW] = show }
suspend fun setBottomAddBar(enabled: Boolean) = dataStore.edit { it[BOTTOM_ADD_BAR] = enabled }
/** Set (or clear, via [ReminderOverride.Inherit]) a list's reminder override. */
suspend fun setListReminderOverride(listId: Long, override: ReminderOverride) = dataStore.edit { p ->
val current = reminderCodec.parse(p[LIST_REMINDER_OVERRIDE]).toMutableMap()
current.applyReminderOverride(listId, override)
p[LIST_REMINDER_OVERRIDE] = reminderCodec.serialize(current)
}
suspend fun setDefaultEditFields(fields: Set<TaskFormField>) = dataStore.edit {
it[DEFAULT_EDIT_FIELDS] = fields.mapTo(mutableSetOf()) { field -> field.name }
}
private companion object {
val THEME_MODE = stringPreferencesKey("theme_mode")
val DYNAMIC_COLOR = booleanPreferencesKey("dynamic_color")
val DEFAULT_LIST_ID = longPreferencesKey("default_list_id")
val REMINDER_LEAD = intPreferencesKey("reminder_lead_minutes")
val REMINDERS_ENABLED = booleanPreferencesKey("reminders_enabled")
val SHOW_ADD_SUBTASK_ROW = booleanPreferencesKey("show_add_subtask_row")
val BOTTOM_ADD_BAR = booleanPreferencesKey("bottom_add_bar")
val REMINDER_ONBOARDING_DONE = booleanPreferencesKey("reminder_onboarding_done")
val LIST_REMINDER_OVERRIDE = stringPreferencesKey("list_reminder_override")
val DEFAULT_EDIT_FIELDS = stringSetPreferencesKey("default_edit_fields")
}
}
/**
* Agendula's stored dialect for the per-list override map: `id:minutes` entries
* joined by `;` (the family default). Fixed at release — don't change without a
* data migration.
*/
private val reminderCodec = ReminderOverrideCodec.DEFAULT

View File

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.floret.data.reminders package de.jeanlucmakiola.agendula.data.reminders
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context

View File

@@ -1,13 +1,15 @@
package de.jeanlucmakiola.floret.data.reminders package de.jeanlucmakiola.agendula.data.reminders
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.floret.data.tasks.TasksDataSource import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@@ -21,6 +23,7 @@ class DueReminderReceiver : BroadcastReceiver() {
@Inject lateinit var dataSource: TasksDataSource @Inject lateinit var dataSource: TasksDataSource
@Inject lateinit var notifier: TaskNotifier @Inject lateinit var notifier: TaskNotifier
@Inject lateinit var settingsPrefs: SettingsPrefs
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@@ -30,6 +33,7 @@ class DueReminderReceiver : BroadcastReceiver() {
val pending = goAsync() val pending = goAsync()
scope.launch { scope.launch {
try { try {
if (!settingsPrefs.settings.first().remindersEnabled) return@launch
val task = runCatching { dataSource.task(taskId) }.getOrNull() val task = runCatching { dataSource.task(taskId) }.getOrNull()
if (task != null && !task.isClosed) notifier.postDue(task) if (task != null && !task.isClosed) notifier.postDue(task)
} finally { } finally {
@@ -39,7 +43,7 @@ class DueReminderReceiver : BroadcastReceiver() {
} }
companion object { companion object {
private const val EXTRA_TASK_ID = "de.jeanlucmakiola.floret.extra.TASK_ID" private const val EXTRA_TASK_ID = "de.jeanlucmakiola.agendula.extra.TASK_ID"
fun intent(context: Context, taskId: Long): Intent = fun intent(context: Context, taskId: Long): Intent =
Intent(context, DueReminderReceiver::class.java).putExtra(EXTRA_TASK_ID, taskId) Intent(context, DueReminderReceiver::class.java).putExtra(EXTRA_TASK_ID, taskId)

View File

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.floret.data.reminders package de.jeanlucmakiola.agendula.data.reminders
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
@@ -12,7 +12,7 @@ import javax.inject.Inject
/** /**
* Re-syncs reminders whenever the tasks provider changes covers external sync * Re-syncs reminders whenever the tasks provider changes covers external sync
* (DAVx5 pulling new/edited tasks) while Floret isn't in the foreground. The * (DAVx5 pulling new/edited tasks) while Agendula isn't in the foreground. The
* manifest filter targets both known authorities; best-effort (the in-app * manifest filter targets both known authorities; best-effort (the in-app
* ContentObserver covers the foreground case regardless). * ContentObserver covers the foreground case regardless).
*/ */

View File

@@ -1,15 +1,15 @@
package de.jeanlucmakiola.floret.data.reminders package de.jeanlucmakiola.agendula.data.reminders
import android.app.AlarmManager import android.app.AlarmManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.os.Build import android.os.Build
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.floret.data.di.IoDispatcher import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.floret.data.prefs.SettingsPrefs import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.floret.data.tasks.ProviderResolver import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.floret.data.tasks.TaskQuery import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
import de.jeanlucmakiola.floret.data.tasks.TasksDataSource import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -18,7 +18,7 @@ import javax.inject.Singleton
/** /**
* The self-scheduled due-reminder engine. Tasks providers don't deliver * The self-scheduled due-reminder engine. Tasks providers don't deliver
* reminders, so Floret reads upcoming due tasks and arms one exact [AlarmManager] * reminders, so Agendula reads upcoming due tasks and arms one exact [AlarmManager]
* alarm each, within a rolling window. Re-run on app start, boot and provider * alarm each, within a rolling window. Re-run on app start, boot and provider
* change; it diffs against [ScheduledReminderStore] so only changed alarms move. * change; it diffs against [ScheduledReminderStore] so only changed alarms move.
*/ */
@@ -33,11 +33,11 @@ class ReminderScheduler @Inject constructor(
) { ) {
suspend fun sync() = withContext(io) { suspend fun sync() = withContext(io) {
val provider = providerResolver.resolve() val provider = providerResolver.resolve()
if (provider == null || !providerResolver.hasPermission(provider)) { val settings = settingsPrefs.settings.first()
if (provider == null || !providerResolver.hasPermission(provider) || !settings.remindersEnabled) {
clearAll() clearAll()
return@withContext return@withContext
} }
val lead = settingsPrefs.settings.first().reminderLeadMinutes.coerceAtLeast(0)
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val horizon = now + WINDOW_MS val horizon = now + WINDOW_MS
val tasks = runCatching { dataSource.tasks(TaskQuery(includeCompleted = false)) } val tasks = runCatching { dataSource.tasks(TaskQuery(includeCompleted = false)) }
@@ -45,7 +45,13 @@ class ReminderScheduler @Inject constructor(
val desired = tasks val desired = tasks
.filter { !it.isClosed && it.due != null } .filter { !it.isClosed && it.due != null }
.associate { it.taskId to (it.due!!.toEpochMilliseconds() - lead * 60_000L) } .mapNotNull { task ->
// The task's list may override the global lead, or opt out entirely
// (override = null), in which case it gets no reminder at all.
val lead = settings.reminderLeadFor(task.listId) ?: return@mapNotNull null
task.taskId to (task.due!!.toEpochMilliseconds() - lead.coerceAtLeast(0) * 60_000L)
}
.toMap()
.filterValues { it in now..horizon } .filterValues { it in now..horizon }
val previous = store.all() val previous = store.all()

View File

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.floret.data.reminders package de.jeanlucmakiola.agendula.data.reminders
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences

View File

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.floret.data.reminders package de.jeanlucmakiola.agendula.data.reminders
import android.Manifest import android.Manifest
import android.annotation.SuppressLint import android.annotation.SuppressLint
@@ -12,9 +12,9 @@ import android.os.Build
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import de.jeanlucmakiola.floret.MainActivity import de.jeanlucmakiola.agendula.MainActivity
import de.jeanlucmakiola.floret.R import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.floret.domain.Task import de.jeanlucmakiola.agendula.domain.Task
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import java.time.Instant as JInstant import java.time.Instant as JInstant
import java.time.ZoneId import java.time.ZoneId

View File

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.floret.data.tasks package de.jeanlucmakiola.agendula.data.tasks
import android.content.ContentResolver import android.content.ContentResolver
import android.content.ContentUris import android.content.ContentUris
@@ -9,12 +9,12 @@ import android.net.Uri
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Instances import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Lists import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Tasks import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.floret.domain.Task import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.floret.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.floret.domain.TaskList import de.jeanlucmakiola.agendula.domain.TaskList
import java.time.ZoneId import java.time.ZoneId
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton

View File

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.floret.data.tasks package de.jeanlucmakiola.agendula.data.tasks
import android.database.Cursor import android.database.Cursor

View File

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.floret.data.tasks package de.jeanlucmakiola.agendula.data.tasks
/** No tasks provider (OpenTasks / tasks.org) is installed on the device. */ /** No tasks provider (OpenTasks / tasks.org) is installed on the device. */
class ProviderUnavailableException : class ProviderUnavailableException :
@@ -7,3 +7,10 @@ class ProviderUnavailableException :
/** A ContentResolver write returned no URI or affected no rows. */ /** A ContentResolver write returned no URI or affected no rows. */
class TaskWriteFailedException(operation: String) : class TaskWriteFailedException(operation: String) :
RuntimeException("Task write failed: $operation") RuntimeException("Task write failed: $operation")
/**
* The task changed (e.g. a DAVx5 sync or another app) since the edit form loaded
* it, so saving would clobber that change. The UI offers to overwrite or reload.
*/
class TaskConflictException(taskId: Long) :
RuntimeException("Task $taskId changed since it was loaded")

View File

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.floret.data.tasks package de.jeanlucmakiola.agendula.data.tasks
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
@@ -8,7 +8,7 @@ import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
/** /**
* A tasks provider Floret can talk to. The same dmfs `TaskProvider` backs every * A tasks provider Agendula can talk to. The same dmfs `TaskProvider` backs every
* candidate, so the [TasksContract] columns apply regardless of which is present. * candidate, so the [TasksContract] columns apply regardless of which is present.
*/ */
data class TaskProvider( data class TaskProvider(

View File

@@ -1,12 +1,12 @@
package de.jeanlucmakiola.floret.data.tasks package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Instances import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Lists import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Tasks import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.floret.domain.Task import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.floret.domain.TaskList import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.floret.domain.priorityFromICal import de.jeanlucmakiola.agendula.domain.priorityFromICal
import de.jeanlucmakiola.floret.domain.statusFromInt import de.jeanlucmakiola.agendula.domain.statusFromInt
import kotlin.time.Instant import kotlin.time.Instant
/** Maps a provider row (via [ColumnReader]) to a domain model. Pure + testable. */ /** Maps a provider row (via [ColumnReader]) to a domain model. Pure + testable. */

View File

@@ -1,8 +1,8 @@
package de.jeanlucmakiola.floret.data.tasks package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Instances import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Lists import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Tasks import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
/** Column lists requested from the provider. Order is irrelevant; we read by name. */ /** Column lists requested from the provider. Order is irrelevant; we read by name. */
object TaskProjections { object TaskProjections {

View File

@@ -1,9 +1,9 @@
package de.jeanlucmakiola.floret.data.tasks package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Lists import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Tasks import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.floret.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.floret.domain.toICal import de.jeanlucmakiola.agendula.domain.toICal
/** /**
* Turns a [TaskForm] / mutation into a namevalue map. Pure (no ContentValues), * Turns a [TaskForm] / mutation into a namevalue map. Pure (no ContentValues),
@@ -17,6 +17,27 @@ object TaskWriteMapper {
put(Tasks.LIST_ID, form.listId) put(Tasks.LIST_ID, form.listId)
put(Tasks.DESCRIPTION, form.description?.trim()?.ifBlank { null }) put(Tasks.DESCRIPTION, form.description?.trim()?.ifBlank { null })
put(Tasks.PRIORITY, form.priority.toICal()) put(Tasks.PRIORITY, form.priority.toICal())
// Keep completion in sync with progress whenever the form carries a
// percent (the Progress field was used). The provider already auto-
// completes at 100% but won't reopen below it — left to itself it strands
// a task "done at 75%" — so we set status both ways. COMPLETED is only
// cleared on reopen; at 100% we leave it for the provider to fill/keep so
// re-saving a finished task doesn't churn its completion timestamp. When
// no percent is present the standalone complete toggle stays authoritative.
val percent = form.percentComplete?.coerceIn(0, 100)
put(Tasks.PERCENT_COMPLETE, percent)
when {
percent == null -> Unit
percent >= 100 -> put(Tasks.STATUS, TasksContract.STATUS_COMPLETED)
percent > 0 -> {
put(Tasks.STATUS, TasksContract.STATUS_IN_PROCESS)
put(Tasks.COMPLETED, null)
}
else -> {
put(Tasks.STATUS, TasksContract.STATUS_NEEDS_ACTION)
put(Tasks.COMPLETED, null)
}
}
put(Tasks.IS_ALLDAY, if (form.isAllDay) 1 else 0) put(Tasks.IS_ALLDAY, if (form.isAllDay) 1 else 0)
put(Tasks.DTSTART, form.start?.toEpochMilliseconds()) put(Tasks.DTSTART, form.start?.toEpochMilliseconds())
put(Tasks.DUE, form.due?.toEpochMilliseconds()) put(Tasks.DUE, form.due?.toEpochMilliseconds())

View File

@@ -1,9 +1,9 @@
package de.jeanlucmakiola.floret.data.tasks package de.jeanlucmakiola.agendula.data.tasks
import android.net.Uri import android.net.Uri
/** /**
* The subset of the OpenTasks `TaskContract` that Floret uses, vendored as * The subset of the OpenTasks `TaskContract` that Agendula uses, vendored as
* Kotlin constants. Derived from `org.dmfs.tasks.contract.TaskContract` * Kotlin constants. Derived from `org.dmfs.tasks.contract.TaskContract`
* (Apache-2.0, dmfs GmbH): * (Apache-2.0, dmfs GmbH):
* https://github.com/dmfs/opentasks/blob/master/opentasks-contract/src/main/java/org/dmfs/tasks/contract/TaskContract.java * https://github.com/dmfs/opentasks/blob/master/opentasks-contract/src/main/java/org/dmfs/tasks/contract/TaskContract.java
@@ -25,7 +25,7 @@ object TasksContract {
const val PARAM_ACCOUNT_TYPE = "account_type" const val PARAM_ACCOUNT_TYPE = "account_type"
const val LOAD_PROPERTIES = "load_properties" const val LOAD_PROPERTIES = "load_properties"
/** Account used for local, device-only task lists Floret owns. */ /** Account used for local, device-only task lists Agendula owns. */
const val LOCAL_ACCOUNT_NAME = "Local" const val LOCAL_ACCOUNT_NAME = "Local"
const val LOCAL_ACCOUNT_TYPE = "org.dmfs.account.LOCAL" const val LOCAL_ACCOUNT_TYPE = "org.dmfs.account.LOCAL"
@@ -85,7 +85,7 @@ object TasksContract {
* The `instances` view one row per task occurrence, with recurrence * The `instances` view one row per task occurrence, with recurrence
* resolved and absolute [INSTANCE_START]/[INSTANCE_DUE] timestamps. Inherits * resolved and absolute [INSTANCE_START]/[INSTANCE_DUE] timestamps. Inherits
* every [Tasks] column (title, status, ) plus the instance-only columns * every [Tasks] column (title, status, ) plus the instance-only columns
* below. This is Floret's read source. * below. This is Agendula's read source.
*/ */
object Instances { object Instances {
const val PATH = "instances" const val PATH = "instances"

View File

@@ -1,8 +1,8 @@
package de.jeanlucmakiola.floret.data.tasks package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.floret.domain.Task import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.floret.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.floret.domain.TaskList import de.jeanlucmakiola.agendula.domain.TaskList
/** What to fetch from the provider. Smart-list date logic is applied above this. */ /** What to fetch from the provider. Smart-list date logic is applied above this. */
data class TaskQuery( data class TaskQuery(

View File

@@ -0,0 +1,46 @@
package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskDetail
import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskList
import kotlinx.coroutines.flow.Flow
import kotlin.time.Instant
/** Whether Agendula can use the tasks provider right now. Drives onboarding. */
enum class ProviderStatus { READY, NEEDS_PERMISSION, NO_PROVIDER }
/**
* The single entry point the UI layer uses. Flows re-emit automatically when the
* provider changes (our writes *and* external sync like DAVx5), via the
* data source's ContentObserver.
*/
interface TasksRepository {
fun taskLists(): Flow<List<TaskList>>
fun tasks(filter: TaskFilter): Flow<List<Task>>
/**
* The direct children of [parentId], re-emitting live like the other flows.
* Lets a smart-list view pull a parent's full subtask set on demand — those
* children are usually filtered out of the list's own (date-based) query.
*/
fun subtasks(parentId: Long): Flow<List<Task>>
fun taskDetail(taskId: Long): Flow<TaskDetail?>
suspend fun createTask(form: TaskForm): Long
/**
* Overwrite [taskId] with [form]. When [expectedLastModified] is non-null, the
* task's current `last_modified` is re-checked first and a
* [TaskConflictException] is thrown if it differs — i.e. something changed it
* since the form loaded. Pass `null` to force the write (overwrite-anyway).
*/
suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant? = null)
suspend fun setCompleted(taskId: Long, completed: Boolean)
suspend fun deleteTask(taskId: Long)
suspend fun createLocalList(name: String, color: Int): Long
/** Synchronous snapshot for the permission/onboarding gate. */
fun providerStatus(): ProviderStatus
}

View File

@@ -1,15 +1,14 @@
package de.jeanlucmakiola.floret.data.tasks package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.floret.data.di.IoDispatcher import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.floret.domain.DayWindow import de.jeanlucmakiola.floret.time.DayWindow
import de.jeanlucmakiola.floret.domain.SmartList import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.floret.domain.Task import de.jeanlucmakiola.agendula.domain.TaskDetail
import de.jeanlucmakiola.floret.domain.TaskDetail import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.floret.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.TaskFiltering
import de.jeanlucmakiola.floret.domain.TaskFiltering import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.floret.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.floret.domain.TaskList import de.jeanlucmakiola.agendula.domain.TaskSorting
import de.jeanlucmakiola.floret.domain.TaskSorting
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
@@ -23,6 +22,7 @@ import java.time.ZoneId
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.Instant
@Singleton @Singleton
class TasksRepositoryImpl @Inject constructor( class TasksRepositoryImpl @Inject constructor(
@@ -35,28 +35,64 @@ class TasksRepositoryImpl @Inject constructor(
override fun tasks(filter: TaskFilter): Flow<List<Task>> = observing { loadTasks(filter) } override fun tasks(filter: TaskFilter): Flow<List<Task>> = observing { loadTasks(filter) }
override fun subtasks(parentId: Long): Flow<List<Task>> = observing {
dataSource.subtasks(parentId)
.filter { it.taskId != parentId }
.sortedWith(TaskSorting.DEFAULT)
}
override fun taskDetail(taskId: Long): Flow<TaskDetail?> = observing { override fun taskDetail(taskId: Long): Flow<TaskDetail?> = observing {
dataSource.task(taskId)?.let { task -> dataSource.task(taskId)?.let { task ->
TaskDetail(task, dataSource.subtasks(taskId).filter { it.taskId != taskId }) TaskDetail(
task = task,
subtasks = dataSource.subtasks(taskId).filter { it.taskId != taskId },
parent = task.parentId?.takeIf { it > 0 }?.let { dataSource.task(it) },
)
} }
} }
private fun loadTasks(filter: TaskFilter): List<Task> { private fun loadTasks(filter: TaskFilter): List<Task> {
val query = when (filter) { val query = when (filter) {
is TaskFilter.OfList -> TaskQuery(listId = filter.listId, includeCompleted = true) is TaskFilter.OfList -> TaskQuery(listId = filter.listId, includeCompleted = true)
is TaskFilter.Smart -> TaskQuery(includeCompleted = filter.list == SmartList.COMPLETED) // Read completed tasks too, then let [TaskFiltering.matches] enforce each
// smart list's own open/closed semantics. We over-read on purpose so a
// parent's subtask-progress reflects ALL its children — completed ones
// included — even on smart lists that hide completed tasks themselves.
is TaskFilter.Smart -> TaskQuery(includeCompleted = true)
} }
val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault()) val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault())
return dataSource.tasks(query) val all = dataSource.tasks(query)
// (done, total) of direct children per parent, over the unfiltered set.
val progress: Map<Long, Pair<Int, Int>> = all
.filter { (it.parentId ?: 0L) > 0L }
.groupingBy { it.parentId!! }
.fold(0 to 0) { (done, total), t ->
(if (t.isCompleted) done + 1 else done) to (total + 1)
}
return all
.filter { TaskFiltering.matches(it, filter, todayStart, todayEnd) } .filter { TaskFiltering.matches(it, filter, todayStart, todayEnd) }
.map { task ->
progress[task.taskId]?.let { (done, total) ->
task.copy(subtaskDone = done, subtaskTotal = total)
} ?: task
}
.sortedWith(TaskSorting.DEFAULT) .sortedWith(TaskSorting.DEFAULT)
} }
override suspend fun createTask(form: TaskForm): Long = override suspend fun createTask(form: TaskForm): Long =
withContext(io) { dataSource.insertTask(form) } withContext(io) { dataSource.insertTask(form) }
override suspend fun updateTask(taskId: Long, form: TaskForm) = override suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant?) =
withContext(io) { dataSource.updateTask(taskId, form) } withContext(io) {
// Conflict-safe overwrite: re-read just before writing and bail if the
// provider's last_modified moved since the form captured it (external
// sync / another app). A null baseline means "force / overwrite anyway".
if (expectedLastModified != null) {
val current = dataSource.task(taskId)?.lastModified
if (current != null && current != expectedLastModified) throw TaskConflictException(taskId)
}
dataSource.updateTask(taskId, form)
}
override suspend fun setCompleted(taskId: Long, completed: Boolean) = override suspend fun setCompleted(taskId: Long, completed: Boolean) =
withContext(io) { dataSource.setCompleted(taskId, completed) } withContext(io) { dataSource.setCompleted(taskId, completed) }

View File

@@ -1,6 +1,6 @@
package de.jeanlucmakiola.floret.domain package de.jeanlucmakiola.agendula.domain
import de.jeanlucmakiola.floret.data.tasks.TasksContract import de.jeanlucmakiola.agendula.data.tasks.TasksContract
import kotlin.time.Instant import kotlin.time.Instant
/** A task list (the `tasklists` table). Lists group under their account. */ /** A task list (the `tasklists` table). Lists group under their account. */
@@ -14,7 +14,7 @@ data class TaskList(
val isVisible: Boolean, val isVisible: Boolean,
val owner: String?, val owner: String?,
) { ) {
/** A device-only list Floret (or another app) created locally, not synced. */ /** A device-only list Agendula (or another app) created locally, not synced. */
val isLocal: Boolean get() = accountType == TasksContract.LOCAL_ACCOUNT_TYPE val isLocal: Boolean get() = accountType == TasksContract.LOCAL_ACCOUNT_TYPE
} }
@@ -52,6 +52,13 @@ data class Task(
val distanceFromCurrent: Int?, val distanceFromCurrent: Int?,
val created: Instant?, val created: Instant?,
val lastModified: Instant?, val lastModified: Instant?,
/**
* Direct-subtask progress, filled in by the repository over the *full* task
* set so a parent's progress chip counts completed children even on smart
* lists that hide them. Both 0 when the task has no subtasks.
*/
val subtaskTotal: Int = 0,
val subtaskDone: Int = 0,
) { ) {
val isCompleted: Boolean get() = status == TaskStatus.COMPLETED val isCompleted: Boolean get() = status == TaskStatus.COMPLETED
val isClosed: Boolean get() = status == TaskStatus.COMPLETED || status == TaskStatus.CANCELLED val isClosed: Boolean get() = status == TaskStatus.COMPLETED || status == TaskStatus.CANCELLED
@@ -60,10 +67,11 @@ data class Task(
val effectiveColor: Int get() = taskColor ?: listColor val effectiveColor: Int get() = taskColor ?: listColor
} }
/** Detail bundle: a task plus its direct children. */ /** Detail bundle: a task, its parent (if it's a subtask), and its direct children. */
data class TaskDetail( data class TaskDetail(
val task: Task, val task: Task,
val subtasks: List<Task>, val subtasks: List<Task>,
val parent: Task? = null,
) )
// --- pure provider <-> domain value mapping (unit-testable) ------------------ // --- pure provider <-> domain value mapping (unit-testable) ------------------

View File

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.floret.domain package de.jeanlucmakiola.agendula.domain
import kotlin.time.Instant import kotlin.time.Instant

View File

@@ -0,0 +1,51 @@
package de.jeanlucmakiola.agendula.domain
import kotlin.time.Instant
/**
* A validated create/edit form. Kept free of Android types so ViewModels and
* tests can build and validate it on the JVM. The data layer turns it into
* provider ContentValues ([de.jeanlucmakiola.agendula.data.tasks.TaskWriteMapper]).
*/
data class TaskForm(
val title: String,
val listId: Long,
val description: String? = null,
val start: Instant? = null,
val due: Instant? = null,
val isAllDay: Boolean = false,
val priority: Priority = Priority.NONE,
val parentId: Long? = null,
/** 0..100 progress; `null` leaves it unset (cleared). */
val percentComplete: Int? = null,
/** Minutes before [due] to fire a reminder; `null` = no reminder. */
val reminderMinutesBeforeDue: Int? = null,
) {
fun validate(): Set<TaskFormError> = buildSet {
if (title.isBlank()) add(TaskFormError.BLANK_TITLE)
if (listId <= 0L) add(TaskFormError.NO_LIST)
if (start != null && due != null && due < start) add(TaskFormError.DUE_BEFORE_START)
if (reminderMinutesBeforeDue != null && due == null) add(TaskFormError.REMINDER_WITHOUT_DUE)
}
val isValid: Boolean get() = validate().isEmpty()
}
enum class TaskFormError { BLANK_TITLE, NO_LIST, DUE_BEFORE_START, REMINDER_WITHOUT_DUE }
/**
* Optional edit-form sections that can be shown or tucked behind "More fields"
* (the core Title / List / When are always visible). Which render by default is
* a setting; the rest unfold on demand. Declaring these as an enum keeps the
* disclosure generic, so adding a field later is one entry plus its card.
*/
enum class TaskFormField { Description, Priority, Progress, Parent, Reminder }
/** The optional fields that already carry a value — auto-revealed when editing. */
fun TaskForm.populatedFields(): Set<TaskFormField> = buildSet {
if (!description.isNullOrBlank()) add(TaskFormField.Description)
if (priority != Priority.NONE) add(TaskFormField.Priority)
if ((percentComplete ?: 0) > 0) add(TaskFormField.Progress)
if (parentId != null && parentId > 0) add(TaskFormField.Parent)
if (reminderMinutesBeforeDue != null) add(TaskFormField.Reminder)
}

View File

@@ -0,0 +1,39 @@
package de.jeanlucmakiola.agendula.domain
import kotlin.time.Instant
/** A due-date bucket a task list groups its rows under. */
enum class TaskSection { OVERDUE, TODAY, UPCOMING, NO_DATE, COMPLETED }
/** A non-empty run of tasks under one [TaskSection], already in display order. */
data class SectionedTasks(val section: TaskSection, val tasks: List<Task>)
/**
* Group already-sorted tasks into due-date sections (the standard task-app
* presentation). Pure + side-effect-free so it unit-tests with a fixed clock,
* mirroring [TaskFiltering] / [DayWindow]. Completed tasks always fall to the
* [TaskSection.COMPLETED] bucket regardless of their due date.
*
* [todayStart] is local midnight today and [todayEnd] local midnight tomorrow
* (compute with [DayWindow]). Only non-empty sections are returned, in the
* fixed order Overdue → Today → Upcoming → No date → Completed.
*/
object TaskSections {
fun of(tasks: List<Task>, todayStart: Instant, todayEnd: Instant): List<SectionedTasks> {
val bySection = tasks.groupBy { sectionOf(it, todayStart, todayEnd) }
return TaskSection.entries.mapNotNull { section ->
bySection[section]?.takeIf { it.isNotEmpty() }?.let { SectionedTasks(section, it) }
}
}
fun sectionOf(task: Task, todayStart: Instant, todayEnd: Instant): TaskSection {
if (task.isCompleted) return TaskSection.COMPLETED
val due = task.due ?: return TaskSection.NO_DATE
return when {
due < todayStart -> TaskSection.OVERDUE
due < todayEnd -> TaskSection.TODAY
else -> TaskSection.UPCOMING
}
}
}

View File

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.floret.domain package de.jeanlucmakiola.agendula.domain
/** Default task ordering, extracted so it's unit-testable without the repository. */ /** Default task ordering, extracted so it's unit-testable without the repository. */
object TaskSorting { object TaskSorting {

View File

@@ -1,7 +1,8 @@
package de.jeanlucmakiola.floret.ui package de.jeanlucmakiola.agendula.ui
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.animation.Crossfade
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -17,15 +18,16 @@ import androidx.compose.ui.res.stringResource
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.floret.R import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.floret.data.tasks.ProviderStatus import de.jeanlucmakiola.agendula.data.tasks.ProviderStatus
import de.jeanlucmakiola.floret.ui.lists.ListsScreen import de.jeanlucmakiola.agendula.ui.navigation.AgendulaNavHost
import de.jeanlucmakiola.floret.ui.permission.PermissionViewModel import de.jeanlucmakiola.agendula.ui.permission.PermissionViewModel
import de.jeanlucmakiola.agendula.ui.permission.ReminderOnboardingScreen
import de.jeanlucmakiola.agendula.ui.permission.ReminderOnboardingViewModel
/** /**
* App root: gates on the tasks-provider permission, then shows the home * App root: gates on the tasks-provider permission, then hands off to
* ([ListsScreen]). Navigation to the task-list / edit screens is wired as those * [AgendulaNavHost] (lists task list detail / edit).
* screens land (next milestones); the callbacks are stubs for now.
*/ */
@Composable @Composable
fun RootScreen( fun RootScreen(
@@ -50,11 +52,31 @@ fun RootScreen(
action = stringResource(R.string.onboarding_permission_button), action = stringResource(R.string.onboarding_permission_button),
onAction = { launcher.launch(permission.permissionsToRequest.toTypedArray()) }, onAction = { launcher.launch(permission.permissionsToRequest.toTypedArray()) },
) )
ProviderStatus.READY -> ListsScreen( ProviderStatus.READY -> ReadyGate(modifier = modifier)
modifier = modifier, }
onOpenFilter = { /* TODO: navigate to TaskListScreen */ }, }
onNewTask = { /* TODO: navigate to TaskEditScreen */ },
) /**
* Second one-time gate after the provider grant: the reminder onboarding step.
* [ReminderOnboardingViewModel.onboardingDone] is null until DataStore's first
* emission render nothing for that frame rather than flash the wrong screen.
* A cross-fade eases the hand-off to the app instead of snapping.
*/
@Composable
private fun ReadyGate(
modifier: Modifier = Modifier,
onboardingViewModel: ReminderOnboardingViewModel = hiltViewModel(),
) {
val done by onboardingViewModel.onboardingDone.collectAsStateWithLifecycle()
Crossfade(targetState = done, label = "reminderOnboardingGate") { state ->
when (state) {
true -> AgendulaNavHost(modifier = modifier)
false -> ReminderOnboardingScreen(
onFinished = onboardingViewModel::finish,
modifier = modifier,
)
null -> {}
}
} }
} }

View File

@@ -0,0 +1,167 @@
package de.jeanlucmakiola.agendula.ui.common
import de.jeanlucmakiola.floret.time.formatDateTime
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Clear
import androidx.compose.material.icons.rounded.Event
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TimePicker
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.material3.rememberTimePickerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import de.jeanlucmakiola.agendula.R
import java.time.LocalDate
import java.time.LocalTime
import java.time.ZoneId
import java.time.ZoneOffset
import kotlin.time.Instant
private val zone: ZoneId get() = ZoneId.systemDefault()
internal fun Instant.toLocalDate(): LocalDate =
java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(zone).toLocalDate()
internal fun Instant.toLocalTime(): LocalTime =
java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(zone).toLocalTime()
internal fun localToInstant(date: LocalDate, time: LocalTime): Instant =
Instant.fromEpochMilliseconds(date.atTime(time).atZone(zone).toInstant().toEpochMilli())
/**
* A labelled date(-time) field for the edit form: a tonal row showing the
* current value (or nothing), tappable to pick a date and — unless [allDay] —
* a time. A clear affordance appears once a value is set. Emits `null` when
* cleared. Styled to match the app's rounded tonal family.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DateTimeField(
label: String,
value: Instant?,
allDay: Boolean,
onChange: (Instant?) -> Unit,
modifier: Modifier = Modifier,
) {
var showDatePicker by remember { mutableStateOf(false) }
var showTimePicker by remember { mutableStateOf(false) }
var pendingDate by remember { mutableStateOf<LocalDate?>(null) }
Surface(
onClick = { showDatePicker = true },
shape = RoundedCornerShape(22.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
modifier = modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(horizontal = 20.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(Icons.Rounded.Event, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
Column(modifier = Modifier.weight(1f)) {
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = value?.formatDateTime(allDay) ?: stringResource(R.string.edit_set),
style = MaterialTheme.typography.bodyLarge,
)
}
if (value != null) {
IconButton(onClick = { onChange(null) }) {
Icon(Icons.Rounded.Clear, contentDescription = stringResource(R.string.edit_clear))
}
}
}
}
if (showDatePicker) {
val initialMillis = (value ?: Instant.fromEpochMilliseconds(System.currentTimeMillis()))
.toLocalDate().atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
val dateState = rememberDatePickerState(initialSelectedDateMillis = initialMillis)
DatePickerDialog(
onDismissRequest = { showDatePicker = false },
confirmButton = {
TextButton(
onClick = {
showDatePicker = false
val millis = dateState.selectedDateMillis ?: return@TextButton
val date = java.time.Instant.ofEpochMilli(millis)
.atZone(ZoneOffset.UTC).toLocalDate()
if (allDay) {
onChange(localToInstant(date, LocalTime.MIDNIGHT))
} else {
pendingDate = date
showTimePicker = true
}
},
) { Text(stringResource(android.R.string.ok)) }
},
dismissButton = {
TextButton(onClick = { showDatePicker = false }) {
Text(stringResource(android.R.string.cancel))
}
},
) { DatePicker(state = dateState) }
}
if (showTimePicker) {
val base = value ?: Instant.fromEpochMilliseconds(System.currentTimeMillis())
val timeState = rememberTimePickerState(
initialHour = base.toLocalTime().hour,
initialMinute = base.toLocalTime().minute,
)
Dialog(onDismissRequest = { showTimePicker = false }) {
Surface(
shape = RoundedCornerShape(28.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
TimePicker(state = timeState)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
TextButton(onClick = { showTimePicker = false }) {
Text(stringResource(android.R.string.cancel))
}
TextButton(onClick = {
showTimePicker = false
val date = pendingDate ?: return@TextButton
onChange(localToInstant(date, LocalTime.of(timeState.hour, timeState.minute)))
}) { Text(stringResource(android.R.string.ok)) }
}
}
}
}
}
}

View File

@@ -1,39 +1,32 @@
package de.jeanlucmakiola.floret.ui.common package de.jeanlucmakiola.agendula.ui.common
import de.jeanlucmakiola.floret.components.pastelize
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Checklist import androidx.compose.material.icons.filled.Checklist
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
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.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
/**
* Soften a raw provider colour toward a pastel that fits the active theme
* keeps the hue (lists stay recognisable), caps saturation so harsh sync
* colours stop screaming, pins brightness to read on light and dark. Copied
* from Calendula so the families' colours behave identically.
*/
fun pastelize(rawArgb: Int, dark: Boolean): Color {
val hsv = FloatArray(3)
android.graphics.Color.colorToHSV(rawArgb, hsv)
hsv[1] = (hsv[1] * 0.6f).coerceIn(0.25f, 0.65f)
hsv[2] = if (dark) 0.82f else 0.72f
return Color(android.graphics.Color.HSVToColor(hsv))
}
/** /**
* Leading avatar for a task list: a neutral round chip holding a checklist glyph * Leading avatar for a task list: a neutral round chip holding a checklist glyph
* tinted in the list's (pastelised) colour the Calendula calendar-chip pattern, * tinted in the list's (pastelised) colour the Calendula calendar-chip pattern,
* with a task icon for Floret. * with a task icon for Agendula.
*/ */
@Composable @Composable
fun ListColorChip(color: Int, modifier: Modifier = Modifier) { fun ListColorChip(color: Int, modifier: Modifier = Modifier) {
@@ -53,3 +46,25 @@ fun ListColorChip(color: Int, modifier: Modifier = Modifier) {
) )
} }
} }
/**
* A small pill naming the task's list, filled in the list's (pastelised) colour.
* The label flips black/white by the fill's luminance so it stays legible on any
* hue in light and dark. Used in mixed views (smart lists) where rows span lists.
*/
@Composable
fun ListNameChip(name: String, color: Int, modifier: Modifier = Modifier) {
val dark = isSystemInDarkTheme()
val fill = pastelize(color, dark)
val onFill = if (fill.luminance() > 0.5f) Color.Black else Color.White
Surface(color = fill, shape = RoundedCornerShape(50), modifier = modifier) {
Text(
text = name,
color = onFill,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
)
}
}

View File

@@ -0,0 +1,131 @@
package de.jeanlucmakiola.agendula.ui.common
import de.jeanlucmakiola.floret.components.pastelize
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Flag
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.agendula.domain.Priority
/**
* Priority has no semantic Material role (M3 only ships `error` for danger), so
* we give the ranked levels their own hues — green / amber / red. They're softened
* harder than the list-colour [pastelize] (lower saturation, higher value) so the
* chips stay gentle and low-key rather than shouting. [Priority.NONE] has no
* colour (callers render it neutral).
*/
fun priorityFill(priority: Priority, dark: Boolean): Color {
val base = when (priority) {
Priority.HIGH -> 0xFFE53935.toInt() // red
Priority.MEDIUM -> 0xFFFFB300.toInt() // amber
Priority.LOW -> 0xFF43A047.toInt() // green
Priority.NONE -> return Color.Unspecified
}
val hsv = FloatArray(3)
android.graphics.Color.colorToHSV(base, hsv)
hsv[1] = if (dark) 0.36f else 0.22f // desaturate toward pastel
hsv[2] = if (dark) 0.58f else 0.92f // light, soft fill in light; muted in dark
return Color(android.graphics.Color.HSVToColor(hsv))
}
/**
* Foreground tint (icon/text) for a priority level — the same red/amber/green as
* [priorityFill], but kept saturated enough to read as a small glyph directly on
* the surface rather than as a soft fill. Used by the task list's quiet meta line,
* where priority is a tinted flag instead of a filled pill. [Priority.NONE] is
* unspecified (callers render it neutral / omit it).
*/
fun priorityAccent(priority: Priority, dark: Boolean): Color {
val base = when (priority) {
Priority.HIGH -> 0xFFE53935.toInt() // red
Priority.MEDIUM -> 0xFFFB8C00.toInt() // orange-amber (darker than the fill for contrast)
Priority.LOW -> 0xFF43A047.toInt() // green
Priority.NONE -> return Color.Unspecified
}
val hsv = FloatArray(3)
android.graphics.Color.colorToHSV(base, hsv)
hsv[1] = if (dark) 0.55f else 0.85f
hsv[2] = if (dark) 0.85f else 0.62f // light on dark surfaces; deep on light ones
return Color(android.graphics.Color.HSVToColor(hsv))
}
/**
* A coloured status pill for a task's priority: a flag plus the level's [label],
* filled in that level's pastel hue. Used read-only in the list and detail
* screens (default [selected] = true), and as a selectable option in the edit
* form — there [selected] = false shows the hue on the flag with an outlined,
* unfilled container, and tapping commits the choice via [onClick].
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PriorityChip(
priority: Priority,
label: String,
modifier: Modifier = Modifier,
selected: Boolean = true,
showIcon: Boolean = true,
onClick: (() -> Unit)? = null,
) {
val dark = isSystemInDarkTheme()
val fill = priorityFill(priority, dark)
val hasColor = fill.isSpecified
val container: Color
val content: Color
val iconTint: Color
var border: BorderStroke? = null
when {
selected && hasColor -> {
container = fill
content = if (fill.luminance() > 0.5f) Color.Black else Color.White
iconTint = content
}
selected -> { // NONE, selected — neutral fill, no hue to show
container = MaterialTheme.colorScheme.surfaceContainerHighest
content = MaterialTheme.colorScheme.onSurfaceVariant
iconTint = content
}
else -> { // an unselected option in a picker — outline, hue kept on the flag
container = Color.Transparent
content = MaterialTheme.colorScheme.onSurfaceVariant
iconTint = if (hasColor) fill else MaterialTheme.colorScheme.onSurfaceVariant
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
}
}
val body: @Composable () -> Unit = {
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (showIcon) {
Icon(Icons.Rounded.Flag, contentDescription = null, tint = iconTint, modifier = Modifier.size(14.dp))
}
Text(label, style = MaterialTheme.typography.labelSmall, color = content)
}
}
val shape = RoundedCornerShape(50)
if (onClick != null) {
Surface(onClick = onClick, color = container, shape = shape, border = border, modifier = modifier) { body() }
} else {
Surface(color = container, shape = shape, border = border, modifier = modifier) { body() }
}
}

View File

@@ -0,0 +1,37 @@
package de.jeanlucmakiola.agendula.ui.common
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.floret.reminders.ReminderUnit
/** Common reminder lead times offered as quick picks in the reminder pickers. */
val REMINDER_PRESETS = listOf(0, 5, 10, 30, 60, 1_440)
@StringRes
fun reminderUnitLabel(unit: ReminderUnit): Int = when (unit) {
ReminderUnit.Minutes -> R.string.reminder_unit_minutes
ReminderUnit.Hours -> R.string.reminder_unit_hours
ReminderUnit.Days -> R.string.reminder_unit_days
ReminderUnit.Weeks -> R.string.reminder_unit_weeks
}
/**
* Humanise a reminder lead time (minutes before due) into one line: "At time of
* task" (0), "10 minutes before", "1 hour before", … Shared by the reminder
* pickers and the settings summaries so the wording never drifts. Ported from
* Calendula.
*/
@Composable
fun reminderLeadTimeLabel(minutes: Int): String = when {
minutes <= 0 -> stringResource(R.string.reminder_at_due)
minutes % 10_080 == 0 ->
pluralStringResource(R.plurals.reminder_weeks, minutes / 10_080, minutes / 10_080)
minutes % 1_440 == 0 ->
pluralStringResource(R.plurals.reminder_days, minutes / 1_440, minutes / 1_440)
minutes % 60 == 0 ->
pluralStringResource(R.plurals.reminder_hours, minutes / 60, minutes / 60)
else -> pluralStringResource(R.plurals.reminder_minutes, minutes, minutes)
}

View File

@@ -0,0 +1,172 @@
package de.jeanlucmakiola.agendula.ui.common
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.components.DialogAmountField
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.floret.reminders.ReminderUnit
import de.jeanlucmakiola.floret.reminders.decomposeReminderMinutes
import androidx.compose.animation.AnimatedVisibility
import de.jeanlucmakiola.floret.identity.collapseExit
import de.jeanlucmakiola.floret.identity.expandEnter
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.agendula.R
/**
* Reminder lead-time picker, full-screen: the grouped list of preset lead times
* plus a "Custom" row that expands an inline amount field + unit selector.
* [allowInherit] adds a "Use default" row (per-list overrides); [allowNone] adds
* a "None" row. Returns the choice as a [ReminderOverride]. Ported from
* Calendula's ReminderDefaultPicker.
*/
@Composable
fun ReminderLeadPicker(
title: String,
selected: ReminderOverride,
allowInherit: Boolean,
allowNone: Boolean,
onSelect: (ReminderOverride) -> Unit,
onDismiss: () -> Unit,
presets: List<Int> = REMINDER_PRESETS,
) {
val selectedMinutes = (selected as? ReminderOverride.Minutes)?.minutes
val customSelected = selectedMinutes != null && selectedMinutes !in presets
val seed = decomposeReminderMinutes(selectedMinutes?.takeIf { customSelected })
var customExpanded by rememberSaveable { mutableStateOf(false) }
var amountText by rememberSaveable { mutableStateOf(seed.amount?.toString() ?: "") }
var unit by rememberSaveable { mutableStateOf(seed.unit) }
val options = buildList {
if (allowInherit) add(ReminderOverride.Inherit)
if (allowNone) add(ReminderOverride.None)
presets.forEach { add(ReminderOverride.Minutes(it)) }
}
val rowCount = options.size + 1 // + the custom row
FullScreenPicker(title = title, onDismiss = onDismiss) {
options.forEachIndexed { index, option ->
val isSelected = option == selected
GroupedRow(
title = reminderOverrideLabel(option),
position = positionOf(index, rowCount),
selected = isSelected,
onClick = {
onSelect(option)
onDismiss()
},
)
}
// Expanded, the Custom row connects downward into the editor card so the
// two read as one grouped container.
GroupedRow(
title = if (customSelected) {
stringResource(R.string.reminder_custom_with_value, reminderLeadTimeLabel(selectedMinutes!!))
} else {
stringResource(R.string.reminder_custom)
},
position = if (customExpanded) Position.Top else positionOf(options.size, rowCount),
selected = customSelected,
onClick = { customExpanded = !customExpanded },
)
AnimatedVisibility(
visible = customExpanded,
enter = expandEnter(),
exit = collapseExit(),
) {
CustomReminderEditor(
amountText = amountText,
onAmountChange = { amountText = it },
unit = unit,
onUnitChange = { unit = it },
onConfirm = { minutes ->
onSelect(ReminderOverride.Minutes(minutes))
onDismiss()
},
)
}
}
}
@Composable
private fun CustomReminderEditor(
amountText: String,
onAmountChange: (String) -> Unit,
unit: ReminderUnit,
onUnitChange: (ReminderUnit) -> Unit,
onConfirm: (Int) -> Unit,
) {
val amount = amountText.toIntOrNull()?.takeIf { it in 1..999 }
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
// A Position.Bottom shape: tight top corners meeting the row, full bottom.
shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 22.dp, bottomEnd = 22.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
ReminderUnit.entries.forEachIndexed { index, entry ->
SegmentedButton(
selected = unit == entry,
onClick = { onUnitChange(entry) },
shape = SegmentedButtonDefaults.itemShape(index, ReminderUnit.entries.size),
label = { Text(stringResource(reminderUnitLabel(entry))) },
)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
DialogAmountField(value = amountText, onValueChange = onAmountChange, placeholder = "10")
Spacer(Modifier.width(16.dp))
Text(
text = amount?.let { reminderLeadTimeLabel(it * unit.minutesFactor) }
?: stringResource(R.string.reminder_custom_amount),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(16.dp))
FilledTonalButton(
onClick = { amount?.let { onConfirm(it * unit.minutesFactor) } },
enabled = amount != null,
) {
Text(stringResource(R.string.reminder_custom_set))
}
}
}
}
}
@Composable
private fun reminderOverrideLabel(override: ReminderOverride): String = when (override) {
ReminderOverride.Inherit -> stringResource(R.string.reminder_use_default)
ReminderOverride.None -> stringResource(R.string.reminder_none)
is ReminderOverride.Minutes -> reminderLeadTimeLabel(override.minutes)
}

View File

@@ -0,0 +1,101 @@
package de.jeanlucmakiola.agendula.ui.common
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialShapes
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.toShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.graphics.shapes.RoundedPolygon
/**
* A top-bar action whose icon sits in a tonal container clipped to one of the
* M3 Expressive [MaterialShapes] (cookie, clover, sunny, …) — Agendula's playful
* take on a plain [androidx.compose.material3.IconButton]. Each action passes
* its own [shape] and [containerColor], so a row of them reads as a set of
* distinct little tokens rather than identical grey glyphs.
*
* Pressing springs the whole shape down and gives it a turn — the scalloped
* container is what dips and spins, while the glyph inside stays upright (a
* spinning magnifier or list icon would just read as wrong). [spinIcon] opts a
* glyph into turning too, for icons that read well mid-spin like the gear.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ShapedActionButton(
shape: RoundedPolygon,
icon: ImageVector,
contentDescription: String?,
onClick: () -> Unit,
modifier: Modifier = Modifier,
containerColor: Color = MaterialTheme.colorScheme.tertiaryContainer,
contentColor: Color = MaterialTheme.colorScheme.onTertiaryContainer,
size: Dp = 40.dp,
iconSize: Dp = 22.dp,
spinIcon: Boolean = false,
) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
// The shape (the scalloped cookie) turns and dips on press.
val shapeRotation by animateFloatAsState(if (pressed) 40f else 0f, label = "shapeRotation")
val scale by animateFloatAsState(if (pressed) 0.82f else 1f, label = "shapeScale")
// The glyph's *net* turn: 0 keeps it upright, spinIcon gives it a quarter turn.
val iconRotation by animateFloatAsState(
if (pressed && spinIcon) 90f else 0f,
label = "iconRotation",
)
Surface(
onClick = onClick,
modifier = modifier
.size(size)
.scale(scale)
.rotate(shapeRotation),
shape = shape.toShape(),
color = containerColor,
contentColor = contentColor,
interactionSource = interaction,
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = icon,
contentDescription = contentDescription,
// Counter the container's turn so the glyph's net rotation is just
// [iconRotation] — upright by default, a quarter turn for the gear.
modifier = Modifier
.size(iconSize)
.rotate(iconRotation - shapeRotation),
)
}
}
}
/**
* Named shapes for the app's top-bar actions, so each action is recognisably
* "its own" and new actions just pick the next unused shape. Expose the
* expressive [MaterialShapes] through a single opt-in site.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
object ActionShapes {
/** Settings — a 4-sided cookie (rounded, scalloped square). */
val Settings: RoundedPolygon get() = MaterialShapes.Cookie4Sided
/** Search — a 6-sided cookie, the same family as [Settings] but distinct. */
val Search: RoundedPolygon get() = MaterialShapes.Cookie6Sided
}

View File

@@ -0,0 +1,61 @@
package de.jeanlucmakiola.agendula.ui.crash
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import de.jeanlucmakiola.agendula.ui.theme.AgendulaTheme
import de.jeanlucmakiola.floret.crash.CrashReportDialog
import de.jeanlucmakiola.floret.crash.CrashReporter
import de.jeanlucmakiola.floret.crash.submitCrashReport
/**
* Standalone surface for a captured crash report. [MainActivity] routes here
* when it detects a startup crash-loop (see [CrashReporter.isCrashLoop]): the
* main UI can't be trusted to start, so this screen stays clear of the app's
* Hilt graph, DataStore-backed theme and Compose content — it only reads the
* report file and shows the kit's report dialog. Plain [AgendulaTheme] defaults
* (follow-system, dynamic colour) avoid touching anything that might be the
* cause of the crash. The reusable machinery lives in floret-kit's core-crash.
*/
class CrashReportActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val report = CrashReporter.pendingReport(this)
if (report == null) {
finish()
return
}
enableEdgeToEdge()
setContent {
AgendulaTheme {
// Opaque backdrop so the dialog doesn't float over a bare task.
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {}
CrashReportDialog(
report = report,
onSend = {
submitCrashReport(this, report)
CrashReporter.clearReport(this)
finish()
},
onDismiss = {
CrashReporter.clearReport(this)
finish()
},
)
}
}
}
override fun onResume() {
super.onResume()
// Reaching this screen breaks the loop; reset the timing trail so a
// later ordinary crash isn't mistaken for a loop.
CrashReporter.markHealthy(this)
}
}

View File

@@ -0,0 +1,528 @@
package de.jeanlucmakiola.agendula.ui.detail
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
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.heightIn
import androidx.compose.foundation.layout.imePadding
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.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.automirrored.rounded.ListAlt
import androidx.compose.material.icons.automirrored.rounded.Notes
import androidx.compose.material.icons.rounded.AccountTree
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.Checklist
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.material.icons.rounded.Flag
import androidx.compose.material.icons.rounded.Percent
import androidx.compose.material.icons.rounded.Schedule
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskDetail
import de.jeanlucmakiola.floret.components.GroupedSurface
import de.jeanlucmakiola.agendula.ui.common.PriorityChip
import de.jeanlucmakiola.floret.time.formatDate
import de.jeanlucmakiola.floret.time.formatTime
import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel
/**
* One task's detail. Each fact is its own tonal card with a leading icon (the
* Calendula detail pattern); only genuinely-grouped things — the subtasks — use
* the grouped-row run. Edit / delete live in the top bar.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TaskDetailScreen(
onEdit: () -> Unit,
onDeleted: () -> Unit,
onBack: () -> Unit,
onOpenTask: (Long) -> Unit,
modifier: Modifier = Modifier,
viewModel: TaskDetailViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.task_detail_title)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = stringResource(R.string.back),
)
}
},
actions = {
val content = state as? TaskDetailUiState.Content
IconButton(onClick = onEdit, enabled = content != null) {
Icon(Icons.Rounded.Edit, contentDescription = stringResource(R.string.edit))
}
IconButton(
onClick = {
content?.let {
viewModel.delete(it.detail.task)
onDeleted()
}
},
enabled = content != null,
) {
Icon(Icons.Rounded.Delete, contentDescription = stringResource(R.string.delete))
}
},
)
},
) { inner ->
when (val s = state) {
TaskDetailUiState.Loading -> Unit
TaskDetailUiState.NotFound ->
CenteredMessage(stringResource(R.string.task_detail_not_found), inner)
is TaskDetailUiState.Content -> DetailBody(
detail = s.detail,
inner = inner,
onToggle = { viewModel.toggleComplete(s.detail.task) },
onToggleSubtask = { viewModel.toggleComplete(it) },
onAddSubtask = { viewModel.addSubtask(it) },
onOpenTask = onOpenTask,
)
}
}
}
@Composable
private fun DetailBody(
detail: TaskDetail,
inner: androidx.compose.foundation.layout.PaddingValues,
onToggle: () -> Unit,
onToggleSubtask: (Task) -> Unit,
onAddSubtask: (String) -> Unit,
onOpenTask: (Long) -> Unit,
) {
val task = detail.task
val dark = isSystemInDarkTheme()
val accent = pastelize(task.effectiveColor, dark)
val gap = 12.dp
var subtasksExpanded by rememberSaveable { mutableStateOf(true) }
Column(
modifier = Modifier
.fillMaxSize()
.padding(inner)
// Shrink the scroll viewport by the keyboard so the focused add-subtask
// field scrolls into view above the IME instead of being hidden.
.imePadding()
.verticalScroll(rememberScrollState())
.padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 40.dp),
) {
// Title + complete toggle, with a short list-coloured accent beneath.
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Checkbox(checked = task.isCompleted, onCheckedChange = { onToggle() })
Text(
text = task.title.ifBlank { stringResource(R.string.task_untitled) },
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.SemiBold,
textDecoration = if (task.isCompleted) TextDecoration.LineThrough else null,
modifier = Modifier.weight(1f),
)
}
Spacer(Modifier.height(10.dp))
Box(
modifier = Modifier
.width(48.dp)
.height(3.dp)
.background(accent, RoundedCornerShape(2.dp)),
)
Spacer(Modifier.height(20.dp))
// One card per fact; gaps between, no shared container (they aren't a group).
val cards = buildList<@Composable () -> Unit> {
// Parent — only when this is a subtask. Conveys that it's filed under
// another task (and so doesn't stand alone in the list); taps open it.
detail.parent?.let { parent ->
add {
DetailCard(
icon = Icons.Rounded.AccountTree,
iconContentDescription = stringResource(R.string.detail_parent),
onClick = { onOpenTask(parent.taskId) },
) {
Text(
text = stringResource(R.string.detail_part_of),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = parent.title.ifBlank { stringResource(R.string.task_untitled) },
style = MaterialTheme.typography.titleMedium,
)
}
}
}
// When — the Calendula time card: date primary, time(s) secondary.
taskWhenLines(task)?.let { (primary, secondary) ->
add {
DetailCard(icon = Icons.Rounded.Schedule, iconContentDescription = stringResource(R.string.detail_due)) {
Text(primary, style = MaterialTheme.typography.titleMedium)
if (secondary != null) {
Spacer(Modifier.height(2.dp))
Text(
secondary,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
// List — icon tinted in the list colour conveys identity (no extra dot).
add {
DetailCard(
icon = Icons.Rounded.Checklist,
iconTint = accent,
iconContentDescription = stringResource(R.string.detail_list),
) {
Text(
text = task.listName ?: stringResource(R.string.detail_list),
style = MaterialTheme.typography.titleMedium,
)
}
}
// Priority — a coloured status chip in the level's hue.
if (task.priority != de.jeanlucmakiola.agendula.domain.Priority.NONE) {
add {
DetailCard(
icon = Icons.Rounded.Flag,
iconContentDescription = stringResource(R.string.detail_priority),
) {
PriorityChip(
priority = task.priority,
label = priorityLabel(task.priority),
showIcon = false,
)
}
}
}
// Progress — shown when the task carries its own percent or has
// subtasks (then the completion ratio fills in if no percent is set).
val hasSubtasks = detail.subtasks.isNotEmpty()
if ((task.percentComplete ?: 0) > 0 || hasSubtasks) {
val pct = task.percentComplete
?: (detail.subtasks.count { it.isCompleted } * 100 / detail.subtasks.size)
add {
DetailCard(icon = Icons.Rounded.Percent, iconContentDescription = stringResource(R.string.detail_progress)) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(stringResource(R.string.detail_progress), style = MaterialTheme.typography.titleMedium)
Text(
stringResource(R.string.percent_complete, pct),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(8.dp))
LinearProgressIndicator(progress = { pct / 100f }, modifier = Modifier.fillMaxWidth())
}
}
}
// Description.
task.description?.takeIf { it.isNotBlank() }?.let { description ->
add {
DetailCard(icon = Icons.AutoMirrored.Rounded.Notes, iconContentDescription = null) {
Text(description, style = MaterialTheme.typography.bodyMedium)
}
}
}
// Subtasks — a genuine group: a connected grouped run whose first
// segment is the expand/collapse toggle. Always shown (even with no
// subtasks yet) so the add field is reachable on any task.
add {
SubtasksGroup(
subtasks = detail.subtasks,
expanded = subtasksExpanded,
onToggle = { subtasksExpanded = !subtasksExpanded },
onToggleSubtask = onToggleSubtask,
onAddSubtask = onAddSubtask,
onOpenSubtask = onOpenTask,
)
}
}
cards.forEachIndexed { index, card ->
if (index > 0) Spacer(Modifier.height(gap))
card()
}
}
}
/**
* Subtasks as a connected grouped run (the GroupedRow family look) at the
* cards' width: the first segment is the expand/collapse toggle (icon,
* "Subtasks", a done/total count, chevron); the rest are the subtask rows.
*/
@Composable
private fun SubtasksGroup(
subtasks: List<Task>,
expanded: Boolean,
onToggle: () -> Unit,
onToggleSubtask: (Task) -> Unit,
onAddSubtask: (String) -> Unit,
onOpenSubtask: (Long) -> Unit,
) {
val done = subtasks.count { it.isCompleted }
// Completed subtasks sink to the bottom; stable, so order is otherwise kept.
val ordered = remember(subtasks) { subtasks.sortedBy { it.isCompleted } }
val rotation by animateFloatAsState(if (expanded) 180f else 0f, label = "subtasksChevron")
// Segments when expanded: header + the always-present add row + each subtask.
val count = if (expanded) subtasks.size + 2 else 1
// First segment: the toggle header.
GroupedSurface(position = positionOf(0, count), onClick = onToggle) {
Row(
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ListAlt,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp),
)
Spacer(Modifier.width(16.dp))
Text(
text = stringResource(R.string.detail_subtasks),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
// No count until there's something to count.
if (subtasks.isNotEmpty()) {
Text(
text = "$done / ${subtasks.size}",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(8.dp))
}
Icon(
imageVector = Icons.Rounded.ExpandMore,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.rotate(rotation),
)
}
}
if (expanded) {
// Always-present add row at the top of the list, in an accent tone.
GroupedSurface(
position = positionOf(1, count),
color = MaterialTheme.colorScheme.primaryContainer,
) {
AddSubtaskField(onAdd = onAddSubtask)
}
ordered.forEachIndexed { index, sub ->
GroupedSurface(
position = positionOf(index + 2, count),
onClick = { onOpenSubtask(sub.taskId) },
) {
Row(
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).padding(start = 8.dp, end = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(checked = sub.isCompleted, onCheckedChange = { onToggleSubtask(sub) })
Spacer(Modifier.width(4.dp))
Text(
text = sub.title.ifBlank { stringResource(R.string.task_untitled) },
style = MaterialTheme.typography.bodyLarge,
textDecoration = if (sub.isCompleted) TextDecoration.LineThrough else null,
color = if (sub.isCompleted) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.onSurface
},
modifier = Modifier.weight(1f),
)
}
}
}
}
}
/**
* Inline "add a subtask" — title only; submits on the tick or the IME action.
* A [BasicTextField] (not the boxed Material field) so its text lines up exactly
* with the subtask titles, the checkbox column matched by the leading + icon.
*/
@Composable
private fun AddSubtaskField(onAdd: (String) -> Unit) {
var text by remember { mutableStateOf("") }
fun submit() {
if (text.isNotBlank()) {
onAdd(text.trim())
text = ""
}
}
val accentOn = MaterialTheme.colorScheme.onPrimaryContainer
Row(
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).padding(start = 8.dp, end = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(modifier = Modifier.size(48.dp), contentAlignment = Alignment.Center) {
Icon(Icons.Rounded.Add, contentDescription = null, tint = accentOn)
}
Spacer(Modifier.width(4.dp))
BasicTextField(
value = text,
onValueChange = { text = it },
singleLine = true,
textStyle = MaterialTheme.typography.bodyLarge.copy(color = accentOn),
cursorBrush = SolidColor(accentOn),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { submit() }),
modifier = Modifier.weight(1f),
decorationBox = { inner ->
if (text.isEmpty()) {
Text(
text = stringResource(R.string.add_task_hint),
style = MaterialTheme.typography.bodyLarge,
color = accentOn.copy(alpha = 0.7f),
)
}
inner()
},
)
if (text.isNotBlank()) {
IconButton(onClick = ::submit) {
Icon(Icons.Rounded.Check, contentDescription = stringResource(R.string.cd_add_task), tint = accentOn)
}
}
}
}
/** One info card: tonal container, leading icon in the gutter, value to the right. */
@Composable
private fun DetailCard(
icon: ImageVector,
iconContentDescription: String?,
iconTint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
onClick: (() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit,
) {
val shape = RoundedCornerShape(16.dp)
val color = MaterialTheme.colorScheme.surfaceContainerHigh
val body: @Composable () -> Unit = {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = icon,
contentDescription = iconContentDescription,
tint = iconTint,
modifier = Modifier.size(24.dp),
)
Spacer(Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f), content = content)
}
}
if (onClick != null) {
Surface(onClick = onClick, color = color, shape = shape, modifier = Modifier.fillMaxWidth()) { body() }
} else {
Surface(color = color, shape = shape, modifier = Modifier.fillMaxWidth()) { body() }
}
}
/**
* The task's "when", formatted like Calendula's event time card: a primary date
* line and an optional secondary time line. A start+due pair reads as a range;
* `null` when the task has no dates at all.
*/
@Composable
private fun taskWhenLines(task: Task): Pair<String, String?>? {
val start = task.start
val due = task.due
return when {
start != null && due != null -> {
val sameDay = start.formatDate() == due.formatDate()
val primary = if (sameDay) due.formatDate() else "${start.formatDate()} ${due.formatDate()}"
val secondary = if (task.isAllDay) null else "${start.formatTime()} ${due.formatTime()}"
primary to secondary
}
due != null -> due.formatDate() to if (task.isAllDay) null else due.formatTime()
start != null -> start.formatDate() to if (task.isAllDay) null else start.formatTime()
else -> null
}
}
@Composable
private fun CenteredMessage(text: String, inner: androidx.compose.foundation.layout.PaddingValues) {
Box(
modifier = Modifier.fillMaxSize().padding(inner).padding(32.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = text,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}

View File

@@ -1,11 +1,12 @@
package de.jeanlucmakiola.floret.ui.detail package de.jeanlucmakiola.agendula.ui.detail
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.floret.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.floret.domain.Task import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.floret.domain.TaskDetail import de.jeanlucmakiola.agendula.domain.TaskDetail
import de.jeanlucmakiola.agendula.domain.TaskForm
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@@ -54,4 +55,15 @@ class TaskDetailViewModel @Inject constructor(
fun delete(task: Task) = viewModelScope.launch { fun delete(task: Task) = viewModelScope.launch {
runCatching { repository.deleteTask(task.taskId) } runCatching { repository.deleteTask(task.taskId) }
} }
/** Add a subtask (title only) under the currently-shown task. */
fun addSubtask(title: String) = viewModelScope.launch {
val parent = (state.value as? TaskDetailUiState.Content)?.detail?.task ?: return@launch
if (title.isBlank()) return@launch
runCatching {
repository.createTask(
TaskForm(title = title.trim(), listId = parent.listId, parentId = parent.taskId),
)
}
}
} }

View File

@@ -0,0 +1,995 @@
package de.jeanlucmakiola.agendula.ui.edit
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.Notes
import androidx.compose.material.icons.rounded.AccountTree
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.ArrowDropDown
import androidx.compose.material.icons.rounded.Checklist
import androidx.compose.material.icons.rounded.Circle
import androidx.compose.material.icons.rounded.Clear
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.Flag
import androidx.compose.material.icons.rounded.Notifications
import androidx.compose.material.icons.rounded.Percent
import androidx.compose.material.icons.rounded.Schedule
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TimePicker
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.rememberTimePickerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.floret.time.DayWindow
import de.jeanlucmakiola.agendula.domain.Priority
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskFormError
import de.jeanlucmakiola.agendula.domain.TaskFormField
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.TaskSection
import de.jeanlucmakiola.agendula.domain.TaskSections
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.OptionalFormSection
import de.jeanlucmakiola.floret.components.InlineTextField
import de.jeanlucmakiola.floret.components.OptionCard
import de.jeanlucmakiola.agendula.ui.common.priorityFill
import de.jeanlucmakiola.floret.time.formatDate
import de.jeanlucmakiola.floret.time.formatTime
import de.jeanlucmakiola.agendula.ui.common.localToInstant
import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.agendula.ui.common.toLocalDate
import de.jeanlucmakiola.agendula.ui.common.toLocalTime
import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel
import java.time.LocalTime
import java.time.ZoneId
import java.time.ZoneOffset
import kotlinx.coroutines.launch
import kotlin.time.Clock
import kotlin.time.Instant
/**
* Create / edit form, in the family's tonal-card language (mirrors Calendula's
* event editor): a borderless headline title with a list-colour accent bar, a
* "When" card with the all-day toggle and tappable schedule rows, a tappable
* list card, and the optional Description / Priority / Reminder sections that
* unfold from "More fields" (which ones start open is a setting). Validated
* through the M1 [TaskEditViewModel] / [de.jeanlucmakiola.agendula.domain.TaskForm];
* the VM flips `saved` once the write lands, which pops us back.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TaskEditScreen(
onSaved: () -> Unit,
onBack: () -> Unit,
modifier: Modifier = Modifier,
viewModel: TaskEditViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(state.saved) { if (state.saved) onSaved() }
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = {},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Rounded.Close, contentDescription = stringResource(R.string.close))
}
},
actions = {
Button(
onClick = viewModel::save,
enabled = !state.loading,
modifier = Modifier.padding(end = 12.dp),
) { Text(stringResource(R.string.save)) }
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
},
) { inner ->
if (state.loading) return@Scaffold
EditContent(
state = state,
viewModel = viewModel,
modifier = Modifier.padding(inner),
)
}
}
private enum class PickerTarget { Start, Due }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun EditContent(
state: TaskEditUiState,
viewModel: TaskEditViewModel,
modifier: Modifier = Modifier,
) {
val dark = isSystemInDarkTheme()
val selectedList = state.lists.firstOrNull { it.id == state.listId }
// The accent ties the form to the detail screen's language: the bar under
// the title takes the target list's colour.
val accent = selectedList?.let { pastelize(it.color, dark) } ?: MaterialTheme.colorScheme.primary
val gap = 12.dp
var pickerTarget by remember { mutableStateOf<PickerTarget?>(null) }
var showListPicker by rememberSaveable { mutableStateOf(false) }
var showParentPicker by rememberSaveable { mutableStateOf(false) }
var showReminderPicker by rememberSaveable { mutableStateOf(false) }
var showFieldPicker by rememberSaveable { mutableStateOf(false) }
Column(
modifier = modifier
// Shrink the scroll viewport by the keyboard so the focused field
// scrolls into view above the IME instead of being hidden.
.imePadding()
.verticalScroll(rememberScrollState())
.padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 40.dp),
) {
// Title: borderless headline + accent bar, mirroring the detail screen.
InlineField(
value = state.title,
onValueChange = viewModel::onTitleChange,
placeholder = stringResource(R.string.edit_title_hint),
textStyle = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
)
Spacer(Modifier.height(10.dp))
Box(
modifier = Modifier
.width(48.dp)
.height(3.dp)
.background(accent, RoundedCornerShape(2.dp)),
)
if (TaskFormError.BLANK_TITLE in state.errors) {
Spacer(Modifier.height(6.dp))
FieldError(R.string.error_blank_title)
}
Spacer(Modifier.height(20.dp))
// "When" card: the icon centres on the all-day row (header); the start /
// due rows continue below in the same text column.
EditCard(
icon = Icons.Rounded.Schedule,
iconContentDescription = stringResource(R.string.edit_when),
header = {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text(
text = stringResource(R.string.edit_all_day),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
Switch(checked = state.isAllDay, onCheckedChange = viewModel::onAllDayChange)
}
},
) {
Spacer(Modifier.height(4.dp))
ScheduleRow(
label = stringResource(R.string.edit_start_label),
value = state.start,
allDay = state.isAllDay,
onPick = { pickerTarget = PickerTarget.Start },
onClear = { viewModel.onStartChange(null) },
)
ScheduleRow(
label = stringResource(R.string.edit_due_label),
value = state.due,
allDay = state.isAllDay,
onPick = { pickerTarget = PickerTarget.Due },
onClear = { viewModel.onDueChange(null) },
isError = TaskFormError.DUE_BEFORE_START in state.errors,
)
if (TaskFormError.DUE_BEFORE_START in state.errors) {
Spacer(Modifier.height(2.dp))
FieldError(R.string.error_due_before_start)
}
}
Spacer(Modifier.height(gap))
// List card — tap anywhere to pick the target list.
EditCard(
icon = Icons.Rounded.Checklist,
iconContentDescription = stringResource(R.string.edit_list_label),
iconTint = accent,
onClick = { showListPicker = true }.takeIf { state.lists.isNotEmpty() },
) {
Text(
text = selectedList?.name ?: stringResource(R.string.error_no_list),
style = MaterialTheme.typography.titleMedium,
color = if (selectedList == null) MaterialTheme.colorScheme.error else Color.Unspecified,
)
selectedList?.accountName?.takeIf { it.isNotBlank() }?.let { account ->
Text(
text = account,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
// Optional sections: which start open is a setting; the rest unfold
// behind the "More fields" button below.
OptionalFormSection(visible = TaskFormField.Description in state.visibleFields) {
Spacer(Modifier.height(gap))
EditCard(
icon = Icons.AutoMirrored.Rounded.Notes,
iconContentDescription = stringResource(R.string.field_description),
iconAtTop = true,
) {
InlineField(
value = state.description,
onValueChange = viewModel::onDescriptionChange,
placeholder = stringResource(R.string.field_description),
singleLine = false,
minLines = 3,
)
}
}
OptionalFormSection(visible = TaskFormField.Priority in state.visibleFields) {
Spacer(Modifier.height(gap))
// Not an EditCard: the four buttons need the card's full width, so
// the label sits on its own row above rather than in the indented
// icon-gutter column (where "Medium" would wrap to a second line).
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = RoundedCornerShape(16.dp),
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Rounded.Flag,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp),
)
Spacer(Modifier.width(16.dp))
Text(
text = stringResource(R.string.edit_priority_label),
style = MaterialTheme.typography.titleMedium,
)
}
Spacer(Modifier.height(12.dp))
// The M3 single-select segmented row, but the active segment
// takes its priority hue so the choice reads in colour.
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
Priority.entries.forEachIndexed { index, priority ->
val fill = priorityFill(priority, dark)
val active = fill.isSpecified
SegmentedButton(
selected = state.priority == priority,
onClick = { viewModel.onPriorityChange(priority) },
shape = SegmentedButtonDefaults.itemShape(index, Priority.entries.size),
colors = SegmentedButtonDefaults.colors(
activeContainerColor = if (active) fill else MaterialTheme.colorScheme.secondaryContainer,
activeContentColor = when {
!active -> MaterialTheme.colorScheme.onSecondaryContainer
fill.luminance() > 0.5f -> Color.Black
else -> Color.White
},
),
// Drop the selected-check icon; with four buttons its
// reserved width is what crowds the labels.
icon = {},
) {
Text(
text = priorityLabel(priority),
maxLines = 1,
style = MaterialTheme.typography.labelLarge,
)
}
}
}
}
}
}
OptionalFormSection(visible = TaskFormField.Progress in state.visibleFields) {
Spacer(Modifier.height(gap))
val pct = state.percentComplete ?: 0
// Full-width like Priority: the slider needs the card's whole width,
// so the label/readout sit on a header row above it.
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = RoundedCornerShape(16.dp),
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Rounded.Percent,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp),
)
Spacer(Modifier.width(16.dp))
Text(
text = stringResource(R.string.edit_progress_label),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
Text(
text = stringResource(R.string.percent_complete, pct),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(4.dp))
Slider(
value = pct.toFloat(),
onValueChange = { viewModel.onPercentChange(it.toInt()) },
valueRange = 0f..100f,
steps = 19, // 5% detents
)
}
}
}
OptionalFormSection(visible = TaskFormField.Parent in state.visibleFields) {
Spacer(Modifier.height(gap))
EditCard(
icon = Icons.Rounded.AccountTree,
iconContentDescription = stringResource(R.string.edit_parent_label),
onClick = { showParentPicker = true },
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = state.parent?.title?.ifBlank { stringResource(R.string.task_untitled) }
?: stringResource(R.string.edit_parent_none),
style = MaterialTheme.typography.titleMedium,
)
Text(
text = stringResource(R.string.edit_parent_label),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Icon(
imageVector = Icons.Rounded.ArrowDropDown,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
OptionalFormSection(visible = TaskFormField.Reminder in state.visibleFields) {
Spacer(Modifier.height(gap))
EditCard(
icon = Icons.Rounded.Notifications,
iconContentDescription = null,
onClick = { showReminderPicker = true },
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(reminderOptionFor(state.reminderMinutesBeforeDue).labelRes),
style = MaterialTheme.typography.titleMedium,
)
Text(
text = stringResource(R.string.edit_reminder_label),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Icon(
imageVector = Icons.Rounded.ArrowDropDown,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (TaskFormError.REMINDER_WITHOUT_DUE in state.errors) {
Spacer(Modifier.height(2.dp))
FieldError(R.string.error_reminder_without_due)
}
}
}
OptionalFormSection(visible = state.hiddenFields.isNotEmpty()) {
Spacer(Modifier.height(20.dp))
TextButton(
onClick = { showFieldPicker = true },
modifier = Modifier.align(Alignment.CenterHorizontally),
) {
Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.edit_more_fields))
}
}
if (state.saveFailed) {
Spacer(Modifier.height(gap))
FieldError(R.string.edit_save_failed)
}
}
pickerTarget?.let { target ->
val current = if (target == PickerTarget.Start) state.start else state.due
DateTimePickerFlow(
initial = current,
allDay = state.isAllDay,
onResult = {
if (target == PickerTarget.Start) viewModel.onStartChange(it) else viewModel.onDueChange(it)
pickerTarget = null
},
onDismiss = { pickerTarget = null },
)
}
if (showListPicker) {
ListPickerDialog(
lists = state.lists,
selectedId = state.listId,
onSelect = { viewModel.onListChange(it); showListPicker = false },
onDismiss = { showListPicker = false },
)
}
if (showParentPicker) {
ParentPickerSheet(
candidates = state.parentCandidates,
selectedId = state.parentId,
onSelect = { viewModel.onParentChange(it); showParentPicker = false },
onDismiss = { showParentPicker = false },
)
}
if (state.saveConflict) {
AlertDialog(
onDismissRequest = viewModel::dismissConflict,
title = { Text(stringResource(R.string.edit_conflict_title)) },
text = { Text(stringResource(R.string.edit_conflict_body)) },
confirmButton = {
TextButton(onClick = { viewModel.save(force = true) }) {
Text(stringResource(R.string.edit_conflict_overwrite))
}
},
dismissButton = {
TextButton(onClick = viewModel::dismissConflict) { Text(stringResource(R.string.dialog_cancel)) }
},
)
}
if (showReminderPicker) {
ReminderPickerDialog(
selected = state.reminderMinutesBeforeDue,
onSelect = { viewModel.onReminderChange(it); showReminderPicker = false },
onDismiss = { showReminderPicker = false },
)
}
if (showFieldPicker) {
FieldPickerDialog(
hiddenFields = state.hiddenFields,
onSelect = { viewModel.revealField(it); showFieldPicker = false },
onDismiss = { showFieldPicker = false },
)
}
}
/**
* Wrapper for an optional form section: fields added via "More fields" spring
* open instead of popping in. (Initially-visible sections render without
* animating on the first frame they're added.)
*/
/**
* One info card mirroring the detail screen's card: tonal container, leading
* icon in the gutter, value to the right. Optionally clickable as a whole.
*/
@Composable
private fun EditCard(
icon: ImageVector,
iconContentDescription: String?,
iconTint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
onClick: (() -> Unit)? = null,
// Multiline-input cards align the icon with the field's first text line.
iconAtTop: Boolean = false,
// Tall cards pass their first row here: the icon centres on it, and
// [content] continues below, indented to the same text column.
header: (@Composable ColumnScope.() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit,
) {
val shape = RoundedCornerShape(16.dp)
val color = MaterialTheme.colorScheme.surfaceContainerHigh
val inner: @Composable () -> Unit = {
if (header != null) {
Column(modifier = Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(icon, iconContentDescription, tint = iconTint, modifier = Modifier.size(24.dp))
Spacer(Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f), content = header)
}
Column(modifier = Modifier.padding(start = 40.dp), content = content)
}
} else {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = if (iconAtTop) Alignment.Top else Alignment.CenterVertically,
) {
Icon(
imageVector = icon,
contentDescription = iconContentDescription,
tint = iconTint,
// 4dp mirrors InlineField's vertical padding, so a
// top-aligned icon centres on the first text line.
modifier = Modifier
.padding(top = if (iconAtTop) 4.dp else 0.dp)
.size(24.dp),
)
Spacer(Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f), content = content)
}
}
}
if (onClick != null) {
Surface(onClick = onClick, color = color, shape = shape, modifier = Modifier.fillMaxWidth()) { inner() }
} else {
Surface(color = color, shape = shape, modifier = Modifier.fillMaxWidth()) { inner() }
}
}
/**
* Borderless input used inside the cards (and as the headline title) — a thin
* wrapper over the shared [InlineTextField] so the form and the rest of the app
* share one input style.
*/
@Composable
private fun InlineField(
value: String,
onValueChange: (String) -> Unit,
placeholder: String,
textStyle: androidx.compose.ui.text.TextStyle = MaterialTheme.typography.titleMedium,
singleLine: Boolean = true,
minLines: Int = 1,
) {
InlineTextField(
value = value,
onValueChange = onValueChange,
placeholder = placeholder,
textStyle = textStyle,
singleLine = singleLine,
minLines = minLines,
)
}
/**
* One schedule row: label, then the tappable date and (unless all-day) time —
* or "Set" when empty. Tapping runs the date → time picker flow; a clear
* affordance appears once a value is set. Tappable values read as links
* (primary); an error flips them to the error colour.
*/
@Composable
private fun ScheduleRow(
label: String,
value: Instant?,
allDay: Boolean,
onPick: () -> Unit,
onClear: () -> Unit,
isError: Boolean = false,
) {
val valueColor = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
if (value == null) {
Text(
text = stringResource(R.string.edit_set),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.clickable(onClick = onPick).padding(vertical = 8.dp, horizontal = 6.dp),
)
} else {
Text(
text = value.formatDate(),
style = MaterialTheme.typography.titleMedium,
color = valueColor,
modifier = Modifier.clickable(onClick = onPick).padding(vertical = 8.dp, horizontal = 6.dp),
)
if (!allDay) {
Text(
text = value.formatTime(),
style = MaterialTheme.typography.titleMedium,
color = valueColor,
modifier = Modifier.clickable(onClick = onPick).padding(vertical = 8.dp, horizontal = 6.dp),
)
}
IconButton(onClick = onClear, modifier = Modifier.size(40.dp)) {
Icon(
Icons.Rounded.Clear,
contentDescription = stringResource(R.string.edit_clear),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
}
}
}
}
private fun nowInstant(): Instant = Instant.fromEpochMilliseconds(System.currentTimeMillis())
/** Pick a date, then (unless all-day) a time; emits the combined instant once. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DateTimePickerFlow(
initial: Instant?,
allDay: Boolean,
onResult: (Instant) -> Unit,
onDismiss: () -> Unit,
) {
var pendingDate by remember { mutableStateOf<java.time.LocalDate?>(null) }
var showTime by remember { mutableStateOf(false) }
if (!showTime) {
val initialMillis = (initial ?: nowInstant())
.toLocalDate().atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
val dateState = rememberDatePickerState(initialSelectedDateMillis = initialMillis)
DatePickerDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = {
val millis = dateState.selectedDateMillis ?: run { onDismiss(); return@TextButton }
val date = java.time.Instant.ofEpochMilli(millis).atZone(ZoneOffset.UTC).toLocalDate()
if (allDay) {
onResult(localToInstant(date, LocalTime.MIDNIGHT))
} else {
pendingDate = date
showTime = true
}
}) { Text(stringResource(android.R.string.ok)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) }
},
) { DatePicker(state = dateState) }
} else {
val base = initial ?: nowInstant()
val timeState = rememberTimePickerState(
initialHour = base.toLocalTime().hour,
initialMinute = base.toLocalTime().minute,
)
AlertDialog(
onDismissRequest = onDismiss,
text = { TimePicker(state = timeState) },
confirmButton = {
TextButton(onClick = {
val date = pendingDate ?: return@TextButton
onResult(localToInstant(date, LocalTime.of(timeState.hour, timeState.minute)))
}) { Text(stringResource(android.R.string.ok)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) }
},
)
}
}
@Composable
private fun ListPickerDialog(
lists: List<TaskList>,
selectedId: Long?,
onSelect: (Long) -> Unit,
onDismiss: () -> Unit,
) {
val dark = isSystemInDarkTheme()
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.edit_list_label)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
lists.forEach { list ->
OptionCard(
label = list.name,
onClick = { onSelect(list.id) },
icon = Icons.Rounded.Circle,
iconTint = pastelize(list.color, dark),
supportingText = list.accountName.takeIf { it.isNotBlank() },
selected = list.id == selectedId,
)
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
},
)
}
/**
* Pick the task to file this one under, or "None" to keep it top-level. A
* full-width modal sheet (not a cramped dialog): a search field filters by title,
* and candidates group under the same due-date sections as the task list
* (Overdue / Today / Upcoming / No date) so the right one is easy to find in a
* long list. Only active tasks are offered (the VM filters closed ones out).
*/
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
private fun ParentPickerSheet(
candidates: List<Task>,
selectedId: Long?,
onSelect: (Long?) -> Unit,
onDismiss: () -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val scope = rememberCoroutineScope()
// Animate the sheet out before reporting the pick, so it doesn't snap shut.
fun choose(id: Long?) {
scope.launch { sheetState.hide() }.invokeOnCompletion {
if (!sheetState.isVisible) onSelect(id)
}
}
var query by rememberSaveable { mutableStateOf("") }
val filtered = remember(candidates, query) {
val q = query.trim()
if (q.isEmpty()) candidates else candidates.filter { it.title.contains(q, ignoreCase = true) }
}
val (todayStart, todayEnd) = remember { DayWindow.today(Clock.System.now(), ZoneId.systemDefault()) }
val sections = remember(filtered) { TaskSections.of(filtered, todayStart, todayEnd) }
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
LazyColumn(
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(bottom = 32.dp),
) {
item(key = "title") {
Text(
text = stringResource(R.string.edit_parent_label),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 12.dp),
)
}
// The search field pins to the top of the sheet while the list scrolls.
stickyHeader(key = "search") {
Surface(color = MaterialTheme.colorScheme.surfaceContainerLow, modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(
value = query,
onValueChange = { query = it },
singleLine = true,
leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = null) },
trailingIcon = {
if (query.isNotEmpty()) {
IconButton(onClick = { query = "" }) {
Icon(Icons.Rounded.Clear, contentDescription = stringResource(R.string.edit_clear))
}
}
},
placeholder = { Text(stringResource(R.string.edit_parent_search)) },
shape = RoundedCornerShape(28.dp),
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 12.dp),
)
}
}
// "None" — promote to top-level. Pinned above the grouped candidates.
item(key = "none") {
GroupedRow(
title = stringResource(R.string.edit_parent_none),
position = de.jeanlucmakiola.floret.components.Position.Alone,
selected = selectedId == null,
minHeight = 56.dp,
onClick = { choose(null) },
modifier = Modifier.padding(bottom = 4.dp),
)
}
if (sections.isEmpty()) {
item(key = "empty") {
Text(
text = stringResource(R.string.edit_parent_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 28.dp, vertical = 24.dp),
)
}
}
sections.forEach { section ->
stickyHeader(key = "hdr-${section.section}") {
SheetSectionHeader(parentSectionLabel(section.section))
}
itemsIndexed(section.tasks, key = { _, t -> t.taskId }) { index, task ->
GroupedRow(
title = task.title.ifBlank { stringResource(R.string.task_untitled) },
position = positionOf(index, section.tasks.size),
summary = task.due?.formatDate(),
selected = task.taskId == selectedId,
minHeight = 56.dp,
onClick = { choose(task.taskId) },
)
}
}
}
}
}
/** Section header inside the parent sheet — opaque so rows scroll cleanly under it. */
@Composable
private fun SheetSectionHeader(text: String) {
Surface(color = MaterialTheme.colorScheme.surfaceContainerLow, modifier = Modifier.fillMaxWidth()) {
Text(
text = text,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 12.dp, bottom = 6.dp),
)
}
}
@Composable
private fun parentSectionLabel(section: TaskSection): String = stringResource(
when (section) {
TaskSection.OVERDUE -> R.string.section_overdue
TaskSection.TODAY -> R.string.section_today
TaskSection.UPCOMING -> R.string.section_upcoming
TaskSection.NO_DATE -> R.string.section_no_date
TaskSection.COMPLETED -> R.string.section_completed
},
)
@Composable
private fun ReminderPickerDialog(
selected: Int?,
onSelect: (Int?) -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.edit_reminder_label)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
reminderOptions.forEach { option ->
OptionCard(
label = stringResource(option.labelRes),
onClick = { onSelect(option.minutes) },
selected = option.minutes == selected,
)
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
},
)
}
/** Picks one hidden optional section to add to the form. */
@Composable
private fun FieldPickerDialog(
hiddenFields: List<TaskFormField>,
onSelect: (TaskFormField) -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.edit_more_fields)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
hiddenFields.forEach { field ->
OptionCard(
label = stringResource(fieldLabel(field)),
onClick = { onSelect(field) },
icon = fieldIcon(field),
)
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
},
)
}
private fun fieldLabel(field: TaskFormField): Int = when (field) {
TaskFormField.Description -> R.string.field_description
TaskFormField.Priority -> R.string.edit_priority_label
TaskFormField.Progress -> R.string.edit_progress_label
TaskFormField.Parent -> R.string.edit_parent_label
TaskFormField.Reminder -> R.string.edit_reminder_label
}
private fun fieldIcon(field: TaskFormField): ImageVector = when (field) {
TaskFormField.Description -> Icons.AutoMirrored.Rounded.Notes
TaskFormField.Priority -> Icons.Rounded.Flag
TaskFormField.Progress -> Icons.Rounded.Percent
TaskFormField.Parent -> Icons.Rounded.AccountTree
TaskFormField.Reminder -> Icons.Rounded.Notifications
}
private data class ReminderOption(val labelRes: Int, val minutes: Int?)
private val reminderOptions = listOf(
ReminderOption(R.string.reminder_none, null),
ReminderOption(R.string.reminder_at_due, 0),
ReminderOption(R.string.reminder_5_min, 5),
ReminderOption(R.string.reminder_10_min, 10),
ReminderOption(R.string.reminder_30_min, 30),
ReminderOption(R.string.reminder_1_hour, 60),
ReminderOption(R.string.reminder_1_day, 1_440),
)
private fun reminderOptionFor(minutes: Int?): ReminderOption =
reminderOptions.firstOrNull { it.minutes == minutes } ?: reminderOptions.first()
@Composable
private fun FieldError(messageRes: Int) {
Text(
text = stringResource(messageRes),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = 4.dp),
)
}

View File

@@ -0,0 +1,237 @@
package de.jeanlucmakiola.agendula.ui.edit
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
import de.jeanlucmakiola.agendula.data.tasks.TaskConflictException
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.domain.Priority
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskFormError
import de.jeanlucmakiola.agendula.domain.TaskFormField
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.populatedFields
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.time.Instant
/**
* Editable form state for creating or editing a task. The screen renders these
* fields and calls the `on*` setters; [save] validates, writes, re-syncs
* reminders, and flips [TaskEditUiState.saved] for the screen to navigate back.
*/
data class TaskEditUiState(
val loading: Boolean = true,
val isNew: Boolean = true,
val title: String = "",
val description: String = "",
val listId: Long? = null,
val start: Instant? = null,
val due: Instant? = null,
val isAllDay: Boolean = false,
val priority: Priority = Priority.NONE,
val parentId: Long? = null,
val percentComplete: Int? = null,
val reminderMinutesBeforeDue: Int? = null,
val lists: List<TaskList> = emptyList(),
/** Top-level tasks in the current list this task can be filed under (reparent). */
val parentCandidates: List<Task> = emptyList(),
/** Optional sections currently shown (core Title / List / When are always on). */
val visibleFields: Set<TaskFormField> = emptySet(),
/** Optional sections still tucked behind "More fields". */
val hiddenFields: List<TaskFormField> = emptyList(),
val errors: Set<TaskFormError> = emptySet(),
val saveFailed: Boolean = false,
/** The task changed underneath us; the screen asks to overwrite or cancel. */
val saveConflict: Boolean = false,
val saved: Boolean = false,
) {
/** The currently-selected parent task, if this task is filed under one. */
val parent: Task? get() = parentCandidates.firstOrNull { it.taskId == parentId }
}
@HiltViewModel
class TaskEditViewModel @Inject constructor(
private val repository: TasksRepository,
private val settingsPrefs: SettingsPrefs,
private val reminderScheduler: ReminderScheduler,
) : ViewModel() {
private val _state = MutableStateFlow(TaskEditUiState())
val state: StateFlow<TaskEditUiState> = _state.asStateFlow()
private var editingTaskId: Long? = null
/** `last_modified` captured when the form loaded — the conflict-check baseline. */
private var baselineLastModified: Instant? = null
/** Sections shown by default (a setting); the rest unfold via [revealField]. */
private var defaultFields: Set<TaskFormField> = emptySet()
/** Start a fresh task, optionally pre-selecting a list / parent. */
fun bindNew(presetListId: Long? = null, parentId: Long? = null) {
editingTaskId = null
baselineLastModified = null
viewModelScope.launch {
val settings = settingsPrefs.settings.first()
defaultFields = settings.defaultEditFields
val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() }
val defaultList = presetListId
?: settings.defaultListId
?: lists.firstOrNull { !it.isLocal }?.id
?: lists.firstOrNull()?.id
_state.value = withFields(
TaskEditUiState(
loading = false,
isNew = true,
listId = defaultList,
parentId = parentId,
lists = lists,
parentCandidates = loadParents(defaultList, selfId = null),
),
)
}
}
/** Load an existing task for editing. */
fun bindEdit(taskId: Long) {
editingTaskId = taskId
viewModelScope.launch {
defaultFields = settingsPrefs.settings.first().defaultEditFields
val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() }
val task = runCatching { repository.taskDetail(taskId).first()?.task }.getOrNull()
if (task == null) {
_state.value = _state.value.copy(loading = false, lists = lists)
return@launch
}
baselineLastModified = task.lastModified
_state.value = withFields(
TaskEditUiState(
loading = false,
isNew = false,
title = task.title,
description = task.description.orEmpty(),
listId = task.listId,
start = task.start,
due = task.due,
isAllDay = task.isAllDay,
priority = task.priority,
parentId = task.parentId,
percentComplete = task.percentComplete,
lists = lists,
parentCandidates = loadParents(task.listId, selfId = taskId),
),
)
}
}
/**
* Tasks the edited task can be filed under: active, top-level tasks in
* [listId], minus the task itself (a task can't parent itself). Restricting to
* top-level keeps the hierarchy one level deep (matching the detail screen),
* and to active so you don't file work under something already done/cancelled.
*/
private suspend fun loadParents(listId: Long?, selfId: Long?): List<Task> {
if (listId == null) return emptyList()
val tasks = runCatching { repository.tasks(TaskFilter.OfList(listId)).first() }.getOrElse { emptyList() }
return tasks.filter { !it.isClosed && !it.isSubtask && it.taskId != selfId }
}
/** Reveal an optional section (one tap from the "More fields" picker). */
fun revealField(field: TaskFormField) = update {
val visible = it.visibleFields + field
it.copy(visibleFields = visible, hiddenFields = hiddenOf(visible))
}
/**
* Resolve which optional sections are visible: the default set, plus any
* field that already carries a value (so editing never hides real data).
*/
private fun withFields(state: TaskEditUiState): TaskEditUiState {
val visible = defaultFields + state.toForm().populatedFields()
return state.copy(visibleFields = visible, hiddenFields = hiddenOf(visible))
}
private fun hiddenOf(visible: Set<TaskFormField>): List<TaskFormField> =
TaskFormField.entries.filterNot { it in visible }
fun onTitleChange(value: String) = update { it.copy(title = value, errors = emptySet()) }
fun onDescriptionChange(value: String) = update { it.copy(description = value) }
/** Switching list invalidates the parent (it lived in the old list); reload candidates. */
fun onListChange(listId: Long) {
if (_state.value.listId == listId) return // re-picking the same list keeps the parent
update { it.copy(listId = listId, parentId = null, errors = emptySet()) }
viewModelScope.launch {
val candidates = loadParents(listId, selfId = editingTaskId)
update { it.copy(parentCandidates = candidates) }
}
}
fun onStartChange(value: Instant?) = update { it.copy(start = value) }
fun onDueChange(value: Instant?) = update { it.copy(due = value) }
fun onAllDayChange(value: Boolean) = update { it.copy(isAllDay = value) }
fun onPriorityChange(value: Priority) = update { it.copy(priority = value) }
fun onPercentChange(value: Int?) = update { it.copy(percentComplete = value?.coerceIn(0, 100)) }
fun onParentChange(parentId: Long?) = update { it.copy(parentId = parentId) }
fun onReminderChange(minutesBeforeDue: Int?) = update { it.copy(reminderMinutesBeforeDue = minutesBeforeDue) }
/** Dismiss the conflict prompt without saving (the user keeps editing). */
fun dismissConflict() = update { it.copy(saveConflict = false) }
/** [force] = overwrite even if the task changed since it loaded (conflict prompt). */
fun save(force: Boolean = false) {
val current = _state.value
val form = current.toForm()
val errors = form.validate()
if (errors.isNotEmpty()) {
_state.value = current.copy(errors = errors)
return
}
viewModelScope.launch {
runCatching {
val id = editingTaskId
if (id == null) {
repository.createTask(form)
} else {
repository.updateTask(id, form, expectedLastModified = if (force) null else baselineLastModified)
}
reminderScheduler.sync()
}.onSuccess {
_state.value = _state.value.copy(saved = true, saveFailed = false, saveConflict = false)
}.onFailure { error ->
_state.value = if (error is TaskConflictException) {
_state.value.copy(saveConflict = true, saveFailed = false)
} else {
_state.value.copy(saveFailed = true)
}
}
}
}
private inline fun update(block: (TaskEditUiState) -> TaskEditUiState) {
_state.value = block(_state.value)
}
}
/** Snapshot the editable state as a validated [TaskForm]. */
private fun TaskEditUiState.toForm(): TaskForm = TaskForm(
title = title,
listId = listId ?: 0L,
description = description.ifBlank { null },
start = start,
due = due,
isAllDay = isAllDay,
priority = priority,
parentId = parentId,
percentComplete = percentComplete,
reminderMinutesBeforeDue = reminderMinutesBeforeDue,
)

View File

@@ -0,0 +1,689 @@
package de.jeanlucmakiola.agendula.ui.lists
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkHorizontally
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ListAlt
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.ChevronRight
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.ErrorOutline
import androidx.compose.material.icons.rounded.Flag
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.icons.rounded.Today
import androidx.compose.material.icons.rounded.Upcoming
import androidx.compose.material3.CircularWavyProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.domain.Priority
import de.jeanlucmakiola.agendula.domain.SmartList
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.ui.common.ActionShapes
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.agendula.ui.common.ListColorChip
import de.jeanlucmakiola.agendula.ui.common.ShapedActionButton
import de.jeanlucmakiola.agendula.ui.common.priorityAccent
import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.time.formatDateTimeCompact
import java.time.LocalDate
import java.time.ZoneId
/**
* Home: a Today progress hero (wavy ring over tasks due today), an Overdue + All
* 2-up of tonal tiles, a live preview of the next upcoming tasks, then the user's
* lists grouped by account. Tapping a tile opens that smart list, an upcoming row
* opens that task, and the FAB starts a new task. The search action in the top bar
* slides open into a full-width field that filters every task by title.
*/
@Composable
fun ListsScreen(
onOpenFilter: (TaskFilter) -> Unit,
onOpenTask: (Long) -> Unit,
onNewTask: () -> Unit,
onOpenSettings: () -> Unit,
modifier: Modifier = Modifier,
viewModel: ListsViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
var query by rememberSaveable { mutableStateOf("") }
var searchActive by rememberSaveable { mutableStateOf(false) }
val closeSearch = {
query = ""
searchActive = false
}
// System back closes search before leaving the screen.
BackHandler(enabled = searchActive, onBack = closeSearch)
Scaffold(
modifier = modifier,
topBar = {
HomeTopBar(
searchActive = searchActive,
query = query,
onQueryChange = { query = it },
onToggleSearch = { if (searchActive) closeSearch() else searchActive = true },
onOpenSettings = onOpenSettings,
)
},
floatingActionButton = {
// The FAB would otherwise float over the search results.
if (!searchActive) {
ExtendedFloatingActionButton(
onClick = onNewTask,
icon = { Icon(Icons.Rounded.Add, contentDescription = null) },
text = { Text(stringResource(R.string.new_task)) },
)
}
},
) { inner ->
Box(Modifier.fillMaxSize().padding(top = inner.calculateTopPadding())) {
when (val s = state) {
ListsUiState.Loading -> Unit // brief; avoids a flash before first emission
ListsUiState.Failure ->
CenteredMessage(stringResource(R.string.lists_failure), PaddingValues(0.dp))
is ListsUiState.Content -> {
ListsContent(
state = s,
onOpenFilter = onOpenFilter,
onOpenTask = onOpenTask,
topPadding = 0.dp,
bottomPadding = inner.calculateBottomPadding() + 96.dp,
)
// While searching with a non-blank query, results cover the home
// content; an empty query leaves the home content visible behind
// the open field.
if (searchActive) {
SearchResults(
query = query,
allTasks = s.allTasks,
onOpenTask = { taskId ->
closeSearch()
onOpenTask(taskId)
},
modifier = Modifier.fillMaxSize(),
)
}
}
}
}
}
}
@Composable
private fun ListsContent(
state: ListsUiState.Content,
onOpenFilter: (TaskFilter) -> Unit,
onOpenTask: (Long) -> Unit,
topPadding: androidx.compose.ui.unit.Dp,
bottomPadding: androidx.compose.ui.unit.Dp,
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
top = topPadding,
bottom = bottomPadding,
),
) {
item {
TodayHero(
done = state.todayDone,
total = state.todayTotal,
onClick = { onOpenFilter(TaskFilter.Smart(SmartList.TODAY)) },
)
}
// Overdue + All sit below the Today hero as a quieter 2-up; Upcoming is no
// longer a count tile — it becomes the live preview further down.
item {
SmartPairRow(
counts = state.smartCounts.filter {
it.smart == SmartList.OVERDUE || it.smart == SmartList.ALL
},
onOpenFilter = onOpenFilter,
)
}
if (state.upcoming.isNotEmpty()) {
item { SectionHeader(stringResource(R.string.smart_upcoming)) }
item {
UpcomingPreview(
tasks = state.upcoming,
onOpenTask = onOpenTask,
onViewAll = { onOpenFilter(TaskFilter.Smart(SmartList.UPCOMING)) },
)
}
}
if (state.groups.isEmpty()) {
item { CenteredMessage(stringResource(R.string.lists_empty), PaddingValues(top = 24.dp)) }
} else {
item { SectionHeader(stringResource(R.string.lists_header)) }
state.groups.forEach { group ->
item(key = "acct-${group.accountName}") { AccountHeader(group.accountName) }
itemsIndexed(group.lists, key = { _, o -> o.list.id }) { index, overview ->
GroupedRow(
title = overview.list.name,
position = positionOf(index, group.lists.size),
minHeight = 72.dp,
leading = { ListColorChip(overview.list.color) },
trailing = {
if (overview.openCount > 0) {
Text(
overview.openCount.toString(),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
onClick = { onOpenFilter(TaskFilter.OfList(overview.list.id)) },
)
}
}
}
}
}
/**
* The home top bar. There is no title — the launcher icon already says which app
* this is. Settings is pinned at the right; the search action sits just left of it
* and stays put. Tapping search unfurls a pill to its left (the field grows out
* from the icon's side) while the search icon remains as the bar's fixed trailing
* icon, so neither action moves.
*/
@Composable
private fun HomeTopBar(
searchActive: Boolean,
query: String,
onQueryChange: (String) -> Unit,
onToggleSearch: () -> Unit,
onOpenSettings: () -> Unit,
) {
Surface(color = MaterialTheme.colorScheme.surface) {
Row(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.height(72.dp)
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Search occupies the flexible space left of settings. Pulled into its
// own composable so AnimatedVisibility resolves to the plain overload —
// a RowScope receiver in here would make that call ambiguous.
SearchSlot(
searchActive = searchActive,
query = query,
onQueryChange = onQueryChange,
onToggleSearch = onToggleSearch,
modifier = Modifier.weight(1f),
)
ShapedActionButton(
shape = ActionShapes.Settings,
icon = Icons.Rounded.Settings,
contentDescription = stringResource(R.string.settings_title),
onClick = onOpenSettings,
modifier = Modifier.padding(start = 8.dp),
size = 48.dp,
iconSize = 26.dp,
spinIcon = true,
)
}
}
}
/**
* The search action and the field it unfurls. The magnifier is pinned to the end
* (right) of this slot and never moves; tapping it toggles search. When active a
* pill expands leftward from it ([expandHorizontally] anchored at the end) holding
* the query field, so the icon reads as the bar's fixed trailing icon.
*/
@Composable
private fun SearchSlot(
searchActive: Boolean,
query: String,
onQueryChange: (String) -> Unit,
onToggleSearch: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier, contentAlignment = Alignment.CenterEnd) {
AnimatedVisibility(
visible = searchActive,
enter = expandHorizontally(tween(300), expandFrom = Alignment.End) + fadeIn(tween(280)),
exit = shrinkHorizontally(tween(220), shrinkTowards = Alignment.End) + fadeOut(tween(140)),
) {
SearchPill(
query = query,
onQueryChange = onQueryChange,
modifier = Modifier.fillMaxWidth(),
)
}
// Always on top, at the end — the bar's fixed search icon and toggle.
ShapedActionButton(
shape = ActionShapes.Search,
icon = Icons.Rounded.Search,
contentDescription = stringResource(R.string.home_search_hint),
onClick = onToggleSearch,
size = 48.dp,
iconSize = 26.dp,
)
}
}
/**
* The expanding search input pill: the query field (auto-focused on open) and a
* clear button once there is text. Its trailing 48dp is left empty for the search
* icon that [SearchSlot] overlays at the end.
*/
@Composable
private fun SearchPill(
query: String,
onQueryChange: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.requestFocus() }
Surface(
shape = RoundedCornerShape(28.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
modifier = modifier.height(52.dp),
) {
Row(
modifier = Modifier.padding(start = 18.dp),
verticalAlignment = Alignment.CenterVertically,
) {
BasicTextField(
value = query,
onValueChange = onQueryChange,
singleLine = true,
textStyle = MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.onSurface,
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(),
modifier = Modifier.weight(1f).focusRequester(focusRequester),
decorationBox = { innerField ->
Box(contentAlignment = Alignment.CenterStart) {
if (query.isEmpty()) {
Text(
stringResource(R.string.home_search_hint),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
innerField()
}
},
)
if (query.isNotEmpty()) {
IconButton(onClick = { onQueryChange("") }) {
Icon(
Icons.Rounded.Close,
contentDescription = stringResource(R.string.home_search_clear),
)
}
}
// Space reserved for the search icon SearchSlot overlays at the end.
Spacer(Modifier.width(48.dp))
}
}
}
/**
* Search results overlaying the home content: tasks whose title matches the query,
* across every list (completed included), open ones first. A blank query renders
* nothing so the home content shows through behind the open field.
*/
@Composable
private fun SearchResults(
query: String,
allTasks: List<Task>,
onOpenTask: (Long) -> Unit,
modifier: Modifier = Modifier,
) {
if (query.isBlank()) return
val results = remember(query, allTasks) {
val q = query.trim()
allTasks
.filter { it.title.contains(q, ignoreCase = true) }
.sortedWith(compareBy({ it.isCompleted }, { it.title.lowercase() }))
}
Surface(modifier = modifier, color = MaterialTheme.colorScheme.surface) {
if (results.isEmpty()) {
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.TopCenter) {
Text(
text = stringResource(R.string.home_search_empty, query.trim()),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(results, key = { it.id }) { task ->
UpcomingRow(task = task, onClick = { onOpenTask(task.taskId) })
}
}
}
}
}
/**
* The day's momentum: a wavy progress ring over "x of y done" for tasks due today.
* Tapping opens the Today list. When nothing is due today it drops the ring and
* reads as a calm, finished state rather than an empty 0.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun TodayHero(done: Int, total: Int, onClick: () -> Unit) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val corner by animateDpAsState(if (pressed) 34.dp else 22.dp, label = "todayCorner")
val left = total - done
Surface(
onClick = onClick,
shape = RoundedCornerShape(corner),
color = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
interactionSource = interaction,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).height(140.dp),
) {
Row(
modifier = Modifier.fillMaxSize().padding(20.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(Icons.Rounded.Today, contentDescription = null, modifier = Modifier.size(20.dp))
Text(stringResource(R.string.smart_today), style = MaterialTheme.typography.titleMedium)
}
val headline = when {
total == 0 -> stringResource(R.string.home_today_empty)
left == 0 -> stringResource(R.string.home_today_all_done)
else -> stringResource(R.string.home_today_progress, done, total)
}
Text(headline, style = MaterialTheme.typography.headlineSmall)
if (total > 0 && left > 0) {
Text(
stringResource(R.string.home_today_remaining, left),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f),
)
}
}
if (total > 0) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(84.dp)) {
CircularWavyProgressIndicator(
progress = { done.toFloat() / total },
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.onPrimaryContainer,
trackColor = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.22f),
)
Text("$done/$total", style = MaterialTheme.typography.titleMedium)
}
}
}
}
}
/** Overdue + All as a 2-up row of the existing tonal tiles. */
@Composable
private fun SmartPairRow(counts: List<SmartCount>, onOpenFilter: (TaskFilter) -> Unit) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).padding(top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
counts.forEach { smart ->
SmartCard(
count = smart,
modifier = Modifier.weight(1f),
onClick = { onOpenFilter(TaskFilter.Smart(smart.smart)) },
)
}
if (counts.size == 1) Spacer(Modifier.weight(1f))
}
}
/** A grouped card previewing the next few upcoming tasks, with a "view all" tail. */
@Composable
private fun UpcomingPreview(
tasks: List<Task>,
onOpenTask: (Long) -> Unit,
onViewAll: () -> Unit,
) {
Surface(
shape = RoundedCornerShape(22.dp),
color = MaterialTheme.colorScheme.surfaceContainer,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
) {
Column {
tasks.forEach { task ->
UpcomingRow(task = task, onClick = { onOpenTask(task.taskId) })
}
Surface(onClick = onViewAll, color = Color.Transparent, modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp).padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
stringResource(R.string.home_upcoming_view_all),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.weight(1f))
Icon(
Icons.Rounded.ChevronRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
}
}
}
}
/**
* One slim upcoming row: list-colour avatar, title, then a quiet meta line of the
* relative due date and (if set) a tinted priority flag — the same calm one-line
* treatment as the task list, minus the swipe machinery.
*/
@Composable
private fun UpcomingRow(task: Task, onClick: () -> Unit) {
val dark = isSystemInDarkTheme()
val muted = MaterialTheme.colorScheme.onSurfaceVariant
val metaStyle = MaterialTheme.typography.bodySmall
Surface(onClick = onClick, color = Color.Transparent, modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.fillMaxWidth().heightIn(min = 60.dp).padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
ListColorChip(task.effectiveColor)
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
task.title,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
upcomingDueLabel(task)?.let { Text(it, style = metaStyle, color = muted) }
if (task.priority != Priority.NONE) {
Text("·", style = metaStyle, color = muted)
Icon(
Icons.Rounded.Flag,
contentDescription = null,
tint = priorityAccent(task.priority, dark),
modifier = Modifier.size(13.dp),
)
Text(priorityLabel(task.priority), style = metaStyle, color = muted)
}
}
}
}
}
}
/** "Today" / "Tomorrow" for the near dates, else the compact date. */
@Composable
private fun upcomingDueLabel(task: Task): String? {
val due = task.due ?: return null
val zone = remember { ZoneId.systemDefault() }
val today = remember { LocalDate.now(zone) }
val dueDate = remember(due) {
java.time.Instant.ofEpochMilli(due.toEpochMilliseconds()).atZone(zone).toLocalDate()
}
return when (dueDate) {
today -> stringResource(R.string.home_due_today)
today.plusDays(1) -> stringResource(R.string.home_due_tomorrow)
else -> due.formatDateTimeCompact(task.isAllDay)
}
}
private data class SmartStyle(val icon: ImageVector, val labelRes: Int, val container: Color, val onContainer: Color)
@Composable
private fun smartStyle(smart: SmartList): SmartStyle {
val colors = MaterialTheme.colorScheme
return when (smart) {
SmartList.TODAY -> SmartStyle(Icons.Rounded.Today, R.string.smart_today, colors.primaryContainer, colors.onPrimaryContainer)
SmartList.OVERDUE -> SmartStyle(Icons.Rounded.ErrorOutline, R.string.smart_overdue, colors.errorContainer, colors.onErrorContainer)
SmartList.UPCOMING -> SmartStyle(Icons.Rounded.Upcoming, R.string.smart_upcoming, colors.tertiaryContainer, colors.onTertiaryContainer)
else -> SmartStyle(Icons.AutoMirrored.Rounded.ListAlt, R.string.smart_all, colors.secondaryContainer, colors.onSecondaryContainer)
}
}
/**
* Clean tonal tile. The expressive touch is subtle: rounded corners gently morph
* rounder on press (the same quiet shape behaviour as the lists), not a loud
* silhouette.
*/
@Composable
private fun SmartCard(count: SmartCount, modifier: Modifier = Modifier, onClick: () -> Unit) {
val style = smartStyle(count.smart)
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val corner by animateDpAsState(if (pressed) 34.dp else 22.dp, label = "smartCorner")
Surface(
onClick = onClick,
shape = RoundedCornerShape(corner),
color = style.container,
contentColor = style.onContainer,
interactionSource = interaction,
modifier = modifier.height(116.dp),
) {
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.SpaceBetween,
) {
Icon(style.icon, contentDescription = null)
Column {
Text(count.count.toString(), style = MaterialTheme.typography.headlineMedium)
Text(stringResource(style.labelRes), style = MaterialTheme.typography.labelLarge)
}
}
}
}
@Composable
private fun SectionHeader(text: String) {
Text(
text = text,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 4.dp),
)
}
@Composable
private fun AccountHeader(text: String) {
Text(
text = text,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 12.dp, bottom = 4.dp),
)
}
@Composable
private fun CenteredMessage(text: String, inner: PaddingValues) {
Box(
modifier = Modifier.fillMaxSize().padding(inner).padding(32.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = text,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}

View File

@@ -0,0 +1,115 @@
package de.jeanlucmakiola.agendula.ui.lists
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.floret.time.DayWindow
import de.jeanlucmakiola.agendula.domain.SmartList
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskFiltering
import de.jeanlucmakiola.agendula.domain.TaskList
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import java.time.ZoneId
import javax.inject.Inject
import kotlin.time.Clock
data class ListOverview(val list: TaskList, val openCount: Int)
data class AccountGroup(val accountName: String, val lists: List<ListOverview>)
data class SmartCount(val smart: SmartList, val count: Int)
sealed interface ListsUiState {
data object Loading : ListsUiState
data object Failure : ListsUiState
data class Content(
val smartCounts: List<SmartCount>,
val groups: List<AccountGroup>,
/** Completed vs. total tasks *due today* — drives the progress ring. */
val todayDone: Int,
val todayTotal: Int,
/** The next few open tasks (due tomorrow onward) for the inline preview. */
val upcoming: List<Task>,
/** Every task (open and completed) — the corpus the home search filters. */
val allTasks: List<Task>,
) : ListsUiState
}
private const val UPCOMING_PREVIEW = 3
/** The home overview: smart lists with live counts, then user lists by account. */
@HiltViewModel
class ListsViewModel @Inject constructor(
repository: TasksRepository,
) : ViewModel() {
val state: StateFlow<ListsUiState> =
combine(
repository.taskLists(),
repository.tasks(TaskFilter.Smart(SmartList.ALL)),
// Open smart lists drop completed tasks, but the Today ring needs the
// ones already ticked off to show "x of y done", so read them too.
repository.tasks(TaskFilter.Smart(SmartList.COMPLETED)),
) { lists, openTasks, completedTasks ->
buildContent(lists, openTasks, completedTasks) as ListsUiState
}.catch { emit(ListsUiState.Failure) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListsUiState.Loading)
private fun buildContent(
lists: List<TaskList>,
openTasks: List<Task>,
completedTasks: List<Task>,
): ListsUiState.Content {
val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault())
// Count only top-level tasks: a subtask is represented by its parent (and
// its progress chip), and an open subtask under a *completed* parent must
// not read as an open item here. Mirrors the task list collapsing subtasks.
val topLevel = openTasks.filter { !it.isSubtask }
fun count(smart: SmartList) =
topLevel.count { TaskFiltering.matches(it, TaskFilter.Smart(smart), todayStart, todayEnd) }
val smartCounts = listOf(
SmartCount(SmartList.TODAY, count(SmartList.TODAY)),
SmartCount(SmartList.OVERDUE, count(SmartList.OVERDUE)),
SmartCount(SmartList.UPCOMING, count(SmartList.UPCOMING)),
SmartCount(SmartList.ALL, topLevel.size),
)
// Today ring: completed vs. total tasks *due today*. The numerator is the
// top-level completed tasks whose due date falls in today's window; the
// denominator adds the still-open ones (the Today smart count above).
val openToday = count(SmartList.TODAY)
val completedDueToday = completedTasks.count {
!it.isSubtask && it.due != null && it.due >= todayStart && it.due < todayEnd
}
val todayTotal = openToday + completedDueToday
// Upcoming preview: the next handful of open tasks due tomorrow onward,
// already sorted by the repository's default ordering.
val upcoming = topLevel
.filter { TaskFiltering.matches(it, TaskFilter.Smart(SmartList.UPCOMING), todayStart, todayEnd) }
.take(UPCOMING_PREVIEW)
val openByList = topLevel.groupingBy { it.listId }.eachCount()
val groups = lists
.groupBy { it.accountName }
.map { (account, accountLists) ->
AccountGroup(
accountName = account,
lists = accountLists.map { ListOverview(it, openByList[it.id] ?: 0) },
)
}
.sortedBy { it.accountName.lowercase() }
return ListsUiState.Content(
smartCounts = smartCounts,
groups = groups,
todayDone = completedDueToday,
todayTotal = todayTotal,
upcoming = upcoming,
allTasks = openTasks + completedTasks,
)
}
}

View File

@@ -0,0 +1,126 @@
package de.jeanlucmakiola.agendula.ui.navigation
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleOut
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.ui.detail.TaskDetailScreen
import de.jeanlucmakiola.agendula.ui.detail.TaskDetailViewModel
import de.jeanlucmakiola.agendula.ui.edit.TaskEditScreen
import de.jeanlucmakiola.agendula.ui.edit.TaskEditViewModel
import de.jeanlucmakiola.agendula.ui.lists.ListsScreen
import de.jeanlucmakiola.agendula.ui.settings.SettingsScreen
import de.jeanlucmakiola.agendula.ui.tasklist.TaskListScreen
import de.jeanlucmakiola.agendula.ui.tasklist.TaskListViewModel
/**
* The single in-app back stack: lists → task list → detail / edit.
*
* Each destination resolves its own Hilt [androidx.lifecycle.ViewModel] and
* feeds it the route arguments through that VM's `bind(...)` method (the VMs
* were built render-only in M1 and take their input this way rather than from a
* `SavedStateHandle`). Screens receive plain navigation lambdas so they stay
* unaware of the nav library.
*/
@Composable
fun AgendulaNavHost(modifier: Modifier = Modifier) {
val nav = rememberNavController()
NavHost(
navController = nav,
startDestination = Dest.LISTS,
modifier = modifier,
// Only the screen on top moves; the background is always held static.
// Forward is a plain fade-in over the held screen — calm, not a slide.
// Back is the predictive-back peek: navigation-compose composes the
// screen being revealed underneath and seeks popExit with the gesture, so
// the leaving screen scales down to reveal the (unmoved) previous screen.
enterTransition = { fadeIn() },
exitTransition = { ExitTransition.None },
popEnterTransition = { EnterTransition.None },
popExitTransition = { scaleOut(targetScale = 0.9f) + fadeOut() },
) {
composable(Dest.LISTS) {
ListsScreen(
onOpenFilter = { filter -> nav.navigate(Dest.TaskList.build(filter)) },
onOpenTask = { taskId -> nav.navigate(Dest.TaskDetail.build(taskId)) },
onNewTask = { nav.navigate(Dest.TaskEdit.buildNew()) },
onOpenSettings = { nav.navigate(Dest.SETTINGS) },
)
}
composable(Dest.SETTINGS) {
SettingsScreen(onBack = { nav.popBackStack() })
}
composable(Dest.TaskList.route, arguments = Dest.TaskList.arguments) { entry ->
val args = entry.arguments
val filter = Dest.TaskList.filterOf(
smart = args?.getString(Dest.TaskList.ARG_SMART),
listId = args?.getLong(Dest.TaskList.ARG_LIST_ID) ?: Dest.NO_ID,
)
val vm: TaskListViewModel = hiltViewModel()
LaunchedEffect(filter) { vm.bind(filter) }
TaskListScreen(
filter = filter,
viewModel = vm,
onOpenTask = { taskId -> nav.navigate(Dest.TaskDetail.build(taskId)) },
onNewTask = {
val presetListId = (filter as? TaskFilter.OfList)?.listId
nav.navigate(Dest.TaskEdit.buildNew(presetListId = presetListId))
},
onBack = { nav.popBackStack() },
)
}
composable(Dest.TaskDetail.route, arguments = Dest.TaskDetail.arguments) { entry ->
val taskId = entry.arguments?.getLong(Dest.TaskDetail.ARG_TASK_ID) ?: Dest.NO_ID
val vm: TaskDetailViewModel = hiltViewModel()
LaunchedEffect(taskId) { vm.bind(taskId) }
TaskDetailScreen(
viewModel = vm,
onEdit = { nav.navigate(Dest.TaskEdit.buildEdit(taskId)) },
onDeleted = { nav.popBackStack() },
onBack = { nav.popBackStack() },
// A subtask opens its own detail — another entry on the stack, with
// its own VM bound to that id (it may have children of its own).
onOpenTask = { subtaskId -> nav.navigate(Dest.TaskDetail.build(subtaskId)) },
)
}
composable(Dest.TaskEdit.route, arguments = Dest.TaskEdit.arguments) { entry ->
val args = entry.arguments
val taskId = args?.getLong(Dest.TaskEdit.ARG_TASK_ID) ?: Dest.NO_ID
val presetListId = args?.getLong(Dest.TaskEdit.ARG_PRESET_LIST_ID) ?: Dest.NO_ID
val parentId = args?.getLong(Dest.TaskEdit.ARG_PARENT_ID) ?: Dest.NO_ID
val vm: TaskEditViewModel = hiltViewModel()
LaunchedEffect(taskId, presetListId, parentId) {
if (taskId != Dest.NO_ID) {
vm.bindEdit(taskId)
} else {
vm.bindNew(
presetListId = presetListId.takeIf { it != Dest.NO_ID },
parentId = parentId.takeIf { it != Dest.NO_ID },
)
}
}
TaskEditScreen(
viewModel = vm,
onSaved = { nav.popBackStack() },
onBack = { nav.popBackStack() },
)
}
}
}

View File

@@ -0,0 +1,91 @@
package de.jeanlucmakiola.agendula.ui.navigation
import androidx.navigation.NavType
import androidx.navigation.navArgument
import de.jeanlucmakiola.agendula.domain.SmartList
import de.jeanlucmakiola.agendula.domain.TaskFilter
/**
* String-based route table for the app's [androidx.navigation.NavHost].
*
* Routes are kept stringly-typed (rather than the type-safe `@Serializable`
* API) so we don't have to pull in the kotlinx-serialization Gradle plugin for
* four small destinations. Each destination exposes a `route` template (for
* `composable(...)`) and a builder that fills the arguments (for `navigate`).
*
* Argument convention: optional `Long`s default to [NO_ID] (`-1`) rather than
* being nullable, which sidesteps the lack of a nullable-Long [NavType].
*/
object Dest {
const val NO_ID = -1L
/** Home — smart lists + the user's lists. */
const val LISTS = "lists"
/** App preferences. */
const val SETTINGS = "settings"
/**
* One [TaskFilter]'s tasks. Carries *either* `listId` (a real list) *or*
* `smart` (a [SmartList] name); whichever is set decides the filter.
*/
object TaskList {
const val ARG_LIST_ID = "listId"
const val ARG_SMART = "smart"
const val route = "taskList?$ARG_LIST_ID={$ARG_LIST_ID}&$ARG_SMART={$ARG_SMART}"
val arguments = listOf(
navArgument(ARG_LIST_ID) { type = NavType.LongType; defaultValue = NO_ID },
navArgument(ARG_SMART) { type = NavType.StringType; nullable = true; defaultValue = null },
)
fun build(filter: TaskFilter): String = when (filter) {
is TaskFilter.OfList -> "taskList?$ARG_LIST_ID=${filter.listId}"
is TaskFilter.Smart -> "taskList?$ARG_SMART=${filter.list.name}"
}
/** Reconstruct the filter from a destination's arguments. */
fun filterOf(smart: String?, listId: Long): TaskFilter =
if (smart != null) TaskFilter.Smart(SmartList.valueOf(smart))
else TaskFilter.OfList(listId)
}
/** A single task's detail. */
object TaskDetail {
const val ARG_TASK_ID = "taskId"
const val route = "taskDetail/{$ARG_TASK_ID}"
val arguments = listOf(navArgument(ARG_TASK_ID) { type = NavType.LongType })
fun build(taskId: Long): String = "taskDetail/$taskId"
}
/**
* The create / edit form. `taskId` set ([NO_ID] otherwise) ⇒ edit; for a new
* task, `presetListId` / `parentId` optionally seed the form.
*/
object TaskEdit {
const val ARG_TASK_ID = "taskId"
const val ARG_PRESET_LIST_ID = "presetListId"
const val ARG_PARENT_ID = "parentId"
const val route =
"taskEdit?$ARG_TASK_ID={$ARG_TASK_ID}" +
"&$ARG_PRESET_LIST_ID={$ARG_PRESET_LIST_ID}" +
"&$ARG_PARENT_ID={$ARG_PARENT_ID}"
val arguments = listOf(
navArgument(ARG_TASK_ID) { type = NavType.LongType; defaultValue = NO_ID },
navArgument(ARG_PRESET_LIST_ID) { type = NavType.LongType; defaultValue = NO_ID },
navArgument(ARG_PARENT_ID) { type = NavType.LongType; defaultValue = NO_ID },
)
fun buildEdit(taskId: Long): String = "taskEdit?$ARG_TASK_ID=$taskId"
fun buildNew(presetListId: Long? = null, parentId: Long? = null): String {
val params = buildList {
if (presetListId != null) add("$ARG_PRESET_LIST_ID=$presetListId")
if (parentId != null) add("$ARG_PARENT_ID=$parentId")
}
return if (params.isEmpty()) "taskEdit" else "taskEdit?${params.joinToString("&")}"
}
}
}

View File

@@ -0,0 +1,36 @@
package de.jeanlucmakiola.agendula.ui.permission
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
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.unit.dp
/**
* Agendula's onboarding hero: an icon in the brand squircle. Each app keeps its
* own hero; the surrounding shell comes from floret-kit's OnboardingScaffold.
*/
@Composable
internal fun SquircleHero(icon: ImageVector) {
Box(
modifier = Modifier
.size(128.dp)
.clip(RoundedCornerShape(34.dp))
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(56.dp),
)
}
}

View File

@@ -1,10 +1,10 @@
package de.jeanlucmakiola.floret.ui.permission package de.jeanlucmakiola.agendula.ui.permission
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.floret.data.tasks.ProviderResolver import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.floret.data.tasks.ProviderStatus import de.jeanlucmakiola.agendula.data.tasks.ProviderStatus
import de.jeanlucmakiola.floret.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow

View File

@@ -0,0 +1,114 @@
package de.jeanlucmakiola.agendula.ui.permission
import android.Manifest
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.Notifications
import androidx.compose.material.icons.rounded.Schedule
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
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 de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.floret.components.BenefitRow
import de.jeanlucmakiola.floret.components.OnboardingScaffold
import de.jeanlucmakiola.floret.components.OnboardingSpace
/**
* One-time onboarding step after the tasks-provider grant: explains that Agendula
* delivers due reminders itself (tasks providers don't broadcast reminders) and
* requests `POST_NOTIFICATIONS` (a system dialog on API 33+ only).
*
* Reminders default ON: [onFinished] gets true from the primary action even if
* the system dialog is declined — the OS permission is the real gate, and the
* Settings toggle re-requests it. "Not now" turns the in-app toggle off.
*/
@Composable
fun ReminderOnboardingScreen(
onFinished: (remindersEnabled: Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { onFinished(true) }
OnboardingScaffold(
modifier = modifier,
hero = { SquircleHero(Icons.Rounded.Notifications) },
actions = {
Button(
onClick = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
} else {
onFinished(true)
}
},
modifier = Modifier.fillMaxWidth().height(56.dp),
) {
Text(
text = stringResource(R.string.reminder_onboarding_enable_button),
style = MaterialTheme.typography.titleMedium,
)
}
TextButton(
onClick = { onFinished(false) },
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.reminder_onboarding_skip_button))
}
},
) {
Text(
text = stringResource(R.string.app_name).uppercase(),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
letterSpacing = 2.sp,
)
Spacer(Modifier.height(OnboardingSpace.xs))
Text(
text = stringResource(R.string.reminder_onboarding_title),
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(12.dp))
Text(
text = stringResource(R.string.reminder_onboarding_body),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(OnboardingSpace.xl))
BenefitRow(
icon = Icons.Rounded.Notifications,
title = stringResource(R.string.reminder_benefit_delivery_title),
body = stringResource(R.string.reminder_benefit_delivery_body),
)
Spacer(Modifier.height(OnboardingSpace.sm))
BenefitRow(
icon = Icons.Rounded.Schedule,
title = stringResource(R.string.reminder_benefit_timing_title),
body = stringResource(R.string.reminder_benefit_timing_body),
)
Spacer(Modifier.height(OnboardingSpace.sm))
BenefitRow(
icon = Icons.Rounded.Edit,
title = stringResource(R.string.reminder_benefit_reversible_title),
body = stringResource(R.string.reminder_benefit_reversible_body),
)
}
}

View File

@@ -0,0 +1,39 @@
package de.jeanlucmakiola.agendula.ui.permission
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Gates the one-time reminder onboarding step shown after the tasks-provider
* grant. [onboardingDone] is null until DataStore's first emission so the step
* neither flashes for users who completed it nor gets skipped.
*/
@HiltViewModel
class ReminderOnboardingViewModel @Inject constructor(
private val prefs: SettingsPrefs,
) : ViewModel() {
val onboardingDone: StateFlow<Boolean?> = prefs.reminderOnboardingDone
.map { done -> done as Boolean? }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = null,
)
/** Close the step, recording whether due reminders stay on. */
fun finish(remindersEnabled: Boolean) {
viewModelScope.launch {
prefs.setRemindersEnabled(remindersEnabled)
prefs.setReminderOnboardingDone()
}
}
}

View File

@@ -0,0 +1,647 @@
package de.jeanlucmakiola.agendula.ui.settings
import android.Manifest
import android.app.AlarmManager
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.provider.Settings
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.padding
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.Image
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.Notes
import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Gavel
import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material.icons.rounded.AccountTree
import androidx.compose.material.icons.rounded.Circle
import androidx.compose.material.icons.rounded.Flag
import androidx.compose.material.icons.rounded.Percent
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
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.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
import de.jeanlucmakiola.agendula.domain.TaskFormField
import de.jeanlucmakiola.floret.components.AboutCard
import de.jeanlucmakiola.floret.components.AboutLink
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.LanguagePickerRow
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.agendula.ui.common.ReminderLeadPicker
import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.identity.collapseExit
import de.jeanlucmakiola.floret.identity.expandEnter
import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel
/** The settings sub-screens reached from the hub's category rows. */
private enum class SettingsSection { Appearance, TaskForm, Reminders }
/**
* Token-based accent for a leading icon chip (container / on-container pair),
* so each accent stays correctly paired across theme, dark mode and dynamic
* colour.
*/
private enum class ChipAccent { Neutral, Primary, Tertiary }
/**
* Settings, structured (after Calendula) as a category hub with sliding
* sub-screens. The hub and the sub-screens share [CollapsingScaffold] and the
* grouped-row card system; option lists use the full-screen [OptionPicker].
* A full-screen destination; [onBack] pops it.
*/
@Composable
fun SettingsScreen(
onBack: () -> Unit,
modifier: Modifier = Modifier,
viewModel: SettingsViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
var section by rememberSaveable { mutableStateOf<SettingsSection?>(null) }
// Inside a sub-screen, system back (button or gesture) returns to the hub
// rather than popping the whole Settings destination to the lists overview.
BackHandler(enabled = section != null) { section = null }
Box(
modifier = modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
) {
SettingsHub(onBack = onBack, onOpenSection = { section = it })
SlideInSection(visible = section == SettingsSection.Appearance) {
AppearanceScreen(state = state, viewModel = viewModel, onBack = { section = null })
}
SlideInSection(visible = section == SettingsSection.TaskForm) {
TaskFormScreen(state = state, viewModel = viewModel, onBack = { section = null })
}
SlideInSection(visible = section == SettingsSection.Reminders) {
RemindersScreen(state = state, viewModel = viewModel, onBack = { section = null })
}
}
}
@Composable
private fun SlideInSection(visible: Boolean, content: @Composable () -> Unit) {
AnimatedVisibility(
visible = visible,
enter = slideInHorizontally { it } + fadeIn(),
exit = slideOutHorizontally { it } + fadeOut(),
) { content() }
}
// ---------------------------------------------------------------------------
// Hub
// ---------------------------------------------------------------------------
@Composable
private fun SettingsHub(
onBack: () -> Unit,
onOpenSection: (SettingsSection) -> Unit,
) {
CollapsingScaffold(title = stringResource(R.string.settings_title), onBack = onBack) {
Box(Modifier.padding(horizontal = 16.dp)) {
AboutCard(
logo = { AppLogo() },
appName = stringResource(R.string.app_name),
author = stringResource(R.string.settings_about_author),
primaryLinks = listOf(
AboutLink(
icon = Icons.Default.Code,
label = stringResource(R.string.settings_about_source),
url = stringResource(R.string.about_source_url),
),
AboutLink(
icon = Icons.Default.Gavel,
label = stringResource(R.string.settings_license),
url = stringResource(R.string.about_license_url),
),
),
highlightLink = AboutLink(
icon = Icons.Default.Favorite,
label = stringResource(R.string.settings_about_support),
url = stringResource(R.string.about_support_url),
),
)
}
Spacer(Modifier.height(16.dp))
GroupedRow(
title = stringResource(R.string.settings_section_appearance),
summary = stringResource(R.string.settings_appearance_subtitle),
position = Position.Top,
leading = { CategoryIcon(Icons.Default.Palette, ChipAccent.Neutral) },
onClick = { onOpenSection(SettingsSection.Appearance) },
)
GroupedRow(
title = stringResource(R.string.settings_section_task_form),
summary = stringResource(R.string.settings_task_form_subtitle),
position = Position.Middle,
leading = { CategoryIcon(Icons.Default.Tune, ChipAccent.Tertiary) },
onClick = { onOpenSection(SettingsSection.TaskForm) },
)
GroupedRow(
title = stringResource(R.string.settings_section_reminders),
summary = stringResource(R.string.settings_reminders_subtitle),
position = Position.Middle,
leading = { CategoryIcon(Icons.Default.Notifications, ChipAccent.Primary) },
onClick = { onOpenSection(SettingsSection.Reminders) },
)
LanguagePickerRow(
position = Position.Middle,
title = stringResource(R.string.settings_language),
autoLabel = stringResource(R.string.settings_language_auto),
localesConfig = R.xml.locales_config,
leading = { CategoryIcon(Icons.Default.Language, ChipAccent.Neutral) },
)
ReportProblemRow(position = Position.Bottom)
AppVersionText()
}
}
/** Opens the project's issue tracker; no data leaves the device until submitted. */
@Composable
private fun ReportProblemRow(position: Position) {
val context = LocalContext.current
val issueUrl = stringResource(R.string.report_issue_url)
GroupedRow(
title = stringResource(R.string.settings_report_problem),
summary = stringResource(R.string.settings_report_problem_hint),
position = position,
leading = { CategoryIcon(Icons.Default.BugReport, ChipAccent.Neutral) },
onClick = { openUrl(context, issueUrl) },
)
}
// ---------------------------------------------------------------------------
// Sub-screens
// ---------------------------------------------------------------------------
@Composable
private fun AppearanceScreen(
state: SettingsUiState,
viewModel: SettingsViewModel,
onBack: () -> Unit,
) {
val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
var showTheme by remember { mutableStateOf(false) }
CollapsingScaffold(title = stringResource(R.string.settings_section_appearance), onBack = onBack) {
GroupedRow(
title = stringResource(R.string.settings_theme),
summary = stringResource(themeLabel(state.settings.themeMode)),
position = Position.Top,
onClick = { showTheme = true },
)
GroupedRow(
title = stringResource(R.string.settings_dynamic_color),
summary = if (dynamicColorAvailable) {
stringResource(R.string.settings_dynamic_color_hint)
} else {
stringResource(R.string.settings_dynamic_color_unavailable)
},
position = Position.Bottom,
trailing = {
Switch(
checked = state.settings.dynamicColor && dynamicColorAvailable,
onCheckedChange = viewModel::setDynamicColor,
enabled = dynamicColorAvailable,
)
},
onClick = if (dynamicColorAvailable) {
{ viewModel.setDynamicColor(!state.settings.dynamicColor) }
} else {
null
},
)
}
if (showTheme) {
OptionPicker(
title = stringResource(R.string.settings_theme),
options = ThemeMode.entries,
selected = state.settings.themeMode,
label = { stringResource(themeLabel(it)) },
onSelect = viewModel::setThemeMode,
onDismiss = { showTheme = false },
)
}
}
@Composable
private fun TaskFormScreen(
state: SettingsUiState,
viewModel: SettingsViewModel,
onBack: () -> Unit,
) {
var showDefaultList by remember { mutableStateOf(false) }
CollapsingScaffold(title = stringResource(R.string.settings_section_task_form), onBack = onBack) {
Text(
text = stringResource(R.string.settings_form_fields_hint),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
Spacer(Modifier.height(8.dp))
val fields = TaskFormField.entries
fields.forEachIndexed { index, field ->
val checked = field in state.settings.defaultEditFields
GroupedRow(
title = stringResource(formFieldLabel(field)),
position = positionOf(index, fields.size),
leading = {
Icon(
imageVector = formFieldIcon(field),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
trailing = {
Switch(checked = checked, onCheckedChange = { viewModel.setFormFieldDefault(field, it) })
},
onClick = { viewModel.setFormFieldDefault(field, !checked) },
)
}
Spacer(Modifier.height(24.dp))
GroupedRow(
title = stringResource(R.string.settings_default_list),
summary = defaultListLabel(state),
position = Position.Top,
onClick = { showDefaultList = true },
)
GroupedRow(
title = stringResource(R.string.settings_add_subtask_row),
summary = stringResource(R.string.settings_add_subtask_row_hint),
position = Position.Middle,
trailing = {
Switch(
checked = state.settings.showAddSubtaskRow,
onCheckedChange = viewModel::setShowAddSubtaskRow,
)
},
onClick = { viewModel.setShowAddSubtaskRow(!state.settings.showAddSubtaskRow) },
)
GroupedRow(
title = stringResource(R.string.settings_bottom_add_bar),
summary = stringResource(R.string.settings_bottom_add_bar_hint),
position = Position.Bottom,
trailing = {
Switch(
checked = state.settings.bottomAddBar,
onCheckedChange = viewModel::setBottomAddBar,
)
},
onClick = { viewModel.setBottomAddBar(!state.settings.bottomAddBar) },
)
}
if (showDefaultList) {
val dark = isSystemInDarkTheme()
// null = first available list; the rest are real list ids.
val options = remember(state.lists) { listOf<Long?>(null) + state.lists.map { it.id } }
OptionPicker(
title = stringResource(R.string.settings_default_list),
options = options,
selected = state.settings.defaultListId,
label = { id -> state.lists.firstOrNull { it.id == id }?.name ?: stringResource(R.string.settings_default_list_first) },
leading = { id ->
state.lists.firstOrNull { it.id == id }?.let { list ->
Icon(Icons.Rounded.Circle, contentDescription = null, tint = pastelize(list.color, dark))
}
},
onSelect = { viewModel.setDefaultList(it) },
onDismiss = { showDefaultList = false },
)
}
}
@Composable
private fun RemindersScreen(
state: SettingsUiState,
viewModel: SettingsViewModel,
onBack: () -> Unit,
) {
val context = LocalContext.current
val notifLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { /* pref already set; a denial just leaves the OS gate shut */ }
val toggleReminders: (Boolean) -> Unit = { enabled ->
viewModel.setRemindersEnabled(enabled)
if (enabled &&
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) !=
PackageManager.PERMISSION_GRANTED
) {
notifLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
val canExact = rememberExactAlarmAllowed(context)
val exactAlarmRow = showExactAlarmRow()
val dark = isSystemInDarkTheme()
var showOffset by remember { mutableStateOf(false) }
var perListExpanded by remember { mutableStateOf(false) }
var listPickerId by remember { mutableStateOf<Long?>(null) }
val lists = state.lists.filter { it.id > 0L }
CollapsingScaffold(title = stringResource(R.string.settings_section_reminders), onBack = onBack) {
GroupedRow(
title = stringResource(R.string.settings_reminders),
summary = stringResource(R.string.settings_reminders_hint),
position = Position.Top,
trailing = { Switch(checked = state.settings.remindersEnabled, onCheckedChange = toggleReminders) },
onClick = { toggleReminders(!state.settings.remindersEnabled) },
)
GroupedRow(
title = stringResource(R.string.settings_default_reminder),
summary = reminderLeadTimeLabel(state.settings.reminderLeadMinutes),
position = if (exactAlarmRow) Position.Middle else Position.Bottom,
onClick = { showOffset = true },
)
if (exactAlarmRow) {
GroupedRow(
title = stringResource(R.string.settings_exact_alarms),
summary = stringResource(
if (canExact) R.string.settings_exact_alarms_allowed
else R.string.settings_exact_alarms_blocked,
),
position = Position.Bottom,
onClick = if (canExact) null else ({ context.openExactAlarmSettings() }),
)
}
// Per-list overrides: the whole section folds behind one header. Each
// list may keep, drop, or replace the global default for its tasks.
if (lists.isNotEmpty()) {
Spacer(Modifier.height(24.dp))
// The header and the list rows form one connected run: the header
// opens its bottom (Top) when expanded, and the last list rounds it off.
GroupedRow(
title = stringResource(R.string.settings_list_reminders_title),
summary = stringResource(R.string.settings_list_reminders_hint),
position = if (perListExpanded) Position.Top else Position.Alone,
trailing = {
Icon(
imageVector = if (perListExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
onClick = { perListExpanded = !perListExpanded },
)
AnimatedVisibility(
visible = perListExpanded,
enter = expandEnter(),
exit = collapseExit(),
) {
Column {
lists.forEachIndexed { index, list ->
GroupedRow(
title = list.name,
summary = listOverrideSummary(
listOverrideChoice(state, list.id),
state.settings.reminderLeadMinutes,
),
// Every list is mid-run under the header; the last rounds off.
position = if (index == lists.lastIndex) Position.Bottom else Position.Middle,
leading = {
Icon(Icons.Rounded.Circle, contentDescription = null, tint = pastelize(list.color, dark))
},
onClick = { listPickerId = list.id },
)
}
}
}
}
}
if (showOffset) {
ReminderLeadPicker(
title = stringResource(R.string.settings_default_reminder),
selected = ReminderOverride.Minutes(state.settings.reminderLeadMinutes),
allowInherit = false,
allowNone = false,
onSelect = { if (it is ReminderOverride.Minutes) viewModel.setReminderLeadMinutes(it.minutes) },
onDismiss = { showOffset = false },
)
}
listPickerId?.let { id ->
ReminderLeadPicker(
title = state.lists.firstOrNull { it.id == id }?.name
?: stringResource(R.string.settings_default_reminder),
selected = listOverrideChoice(state, id),
allowInherit = true,
allowNone = true,
onSelect = { viewModel.setListReminderOverride(id, it) },
onDismiss = { listPickerId = null },
)
}
}
/** The stored override for [listId], as a picker choice (absent → inherit). */
private fun listOverrideChoice(state: SettingsUiState, listId: Long): ReminderOverride {
val map = state.settings.perListReminderOverride
return when {
!map.containsKey(listId) -> ReminderOverride.Inherit
map[listId] == null -> ReminderOverride.None
else -> ReminderOverride.Minutes(map.getValue(listId)!!)
}
}
/** Row summary for a list: its override, or the inherited global default. */
@Composable
private fun listOverrideSummary(choice: ReminderOverride, globalDefault: Int): String = when (choice) {
ReminderOverride.Inherit ->
stringResource(R.string.settings_list_reminder_inherits, reminderLeadTimeLabel(globalDefault))
ReminderOverride.None -> stringResource(R.string.reminder_none)
is ReminderOverride.Minutes -> reminderLeadTimeLabel(choice.minutes)
}
// ---------------------------------------------------------------------------
// About + shared building blocks
// ---------------------------------------------------------------------------
@Composable
private fun AppLogo() {
Box(
modifier = Modifier
.size(72.dp)
.clip(RoundedCornerShape(20.dp))
.background(colorResource(R.color.ic_launcher_background)),
contentAlignment = Alignment.Center,
) {
Image(
painter = painterResource(R.drawable.ic_launcher_foreground),
contentDescription = stringResource(R.string.settings_about_logo_desc),
modifier = Modifier.requiredSize(108.dp),
)
}
}
@Composable
private fun AppVersionText() {
val context = LocalContext.current
val versionName = remember {
runCatching { context.packageManager.getPackageInfo(context.packageName, 0).versionName }
.getOrNull() ?: ""
}
Text(
text = stringResource(R.string.settings_about_version, versionName),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp),
)
}
/** Leading circular icon chip; colours come from the M3 scheme via a token pair. */
@Composable
private fun CategoryIcon(icon: ImageVector, accent: ChipAccent) {
val scheme = MaterialTheme.colorScheme
val (background, iconColor) = when (accent) {
ChipAccent.Neutral -> scheme.surfaceContainerHighest to scheme.onSurfaceVariant
ChipAccent.Primary -> scheme.primaryContainer to scheme.onPrimaryContainer
ChipAccent.Tertiary -> scheme.tertiaryContainer to scheme.onTertiaryContainer
}
Box(
modifier = Modifier.size(40.dp).clip(CircleShape).background(background),
contentAlignment = Alignment.Center,
) {
Icon(icon, contentDescription = null, tint = iconColor, modifier = Modifier.size(22.dp))
}
}
@Composable
private fun defaultListLabel(state: SettingsUiState): String =
state.lists.firstOrNull { it.id == state.settings.defaultListId }?.name
?: stringResource(R.string.settings_default_list_first)
private fun openUrl(context: Context, url: String) {
runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri())) }
}
private fun themeLabel(mode: ThemeMode): Int = when (mode) {
ThemeMode.SYSTEM -> R.string.settings_theme_system
ThemeMode.LIGHT -> R.string.settings_theme_light
ThemeMode.DARK -> R.string.settings_theme_dark
}
private fun formFieldLabel(field: TaskFormField): Int = when (field) {
TaskFormField.Description -> R.string.field_description
TaskFormField.Priority -> R.string.edit_priority_label
TaskFormField.Progress -> R.string.edit_progress_label
TaskFormField.Parent -> R.string.edit_parent_label
TaskFormField.Reminder -> R.string.edit_reminder_label
}
private fun formFieldIcon(field: TaskFormField): ImageVector = when (field) {
TaskFormField.Description -> Icons.AutoMirrored.Rounded.Notes
TaskFormField.Priority -> Icons.Rounded.Flag
TaskFormField.Progress -> Icons.Rounded.Percent
TaskFormField.Parent -> Icons.Rounded.AccountTree
TaskFormField.Reminder -> Icons.Default.Notifications
}
/**
* Exact-alarm control is only meaningful on API 31-32 (SCHEDULE_EXACT_ALARM,
* revocable). On 33+ the app holds USE_EXACT_ALARM (always granted), so the row
* would be a permanent no-op — hide it there.
*/
private fun showExactAlarmRow(): Boolean =
Build.VERSION.SDK_INT in Build.VERSION_CODES.S..Build.VERSION_CODES.S_V2
@Composable
private fun rememberExactAlarmAllowed(context: Context): Boolean {
var allowed by remember {
mutableStateOf(
Build.VERSION.SDK_INT < Build.VERSION_CODES.S ||
context.getSystemService(AlarmManager::class.java).canScheduleExactAlarms(),
)
}
val lifecycle = LocalLifecycleOwner.current.lifecycle
DisposableEffect(lifecycle) {
val obs = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
allowed = context.getSystemService(AlarmManager::class.java).canScheduleExactAlarms()
}
}
lifecycle.addObserver(obs)
onDispose { lifecycle.removeObserver(obs) }
}
return allowed
}
private fun Context.openExactAlarmSettings() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
runCatching {
startActivity(
Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM).setData("package:$packageName".toUri()),
)
}
}
}

View File

@@ -1,13 +1,15 @@
package de.jeanlucmakiola.floret.ui.settings package de.jeanlucmakiola.agendula.ui.settings
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.floret.data.prefs.Settings import de.jeanlucmakiola.agendula.data.prefs.Settings
import de.jeanlucmakiola.floret.data.prefs.SettingsPrefs import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.floret.data.prefs.ThemeMode import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
import de.jeanlucmakiola.floret.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.floret.domain.TaskList import de.jeanlucmakiola.agendula.domain.TaskFormField
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.floret.reminders.ReminderOverride
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
@@ -40,4 +42,17 @@ class SettingsViewModel @Inject constructor(
fun setDynamicColor(enabled: Boolean) = viewModelScope.launch { prefs.setDynamicColor(enabled) } fun setDynamicColor(enabled: Boolean) = viewModelScope.launch { prefs.setDynamicColor(enabled) }
fun setDefaultList(id: Long?) = viewModelScope.launch { prefs.setDefaultListId(id) } fun setDefaultList(id: Long?) = viewModelScope.launch { prefs.setDefaultListId(id) }
fun setReminderLeadMinutes(minutes: Int) = viewModelScope.launch { prefs.setReminderLeadMinutes(minutes) } fun setReminderLeadMinutes(minutes: Int) = viewModelScope.launch { prefs.setReminderLeadMinutes(minutes) }
fun setRemindersEnabled(enabled: Boolean) = viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
fun setShowAddSubtaskRow(show: Boolean) = viewModelScope.launch { prefs.setShowAddSubtaskRow(show) }
fun setBottomAddBar(enabled: Boolean) = viewModelScope.launch { prefs.setBottomAddBar(enabled) }
/** Toggle whether [field] shows by default on a new task's edit form. */
fun setFormFieldDefault(field: TaskFormField, enabled: Boolean) = viewModelScope.launch {
val current = state.value.settings.defaultEditFields
prefs.setDefaultEditFields(if (enabled) current + field else current - field)
}
/** Set (or clear, via [ReminderOverride.Inherit]) a list's reminder override. */
fun setListReminderOverride(listId: Long, override: ReminderOverride) =
viewModelScope.launch { prefs.setListReminderOverride(listId, override) }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,161 @@
package de.jeanlucmakiola.agendula.ui.tasklist
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskForm
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
sealed interface TaskListUiState {
data object Loading : TaskListUiState
data object Failure : TaskListUiState
/**
* [listName] is the real list's name when the filter is a
* [TaskFilter.OfList] (for the top-bar title), `null` for smart lists —
* the screen falls back to the smart label in that case.
*/
data class Content(
val tasks: List<Task>,
val listName: String? = null,
/** Whether the inline "add a subtask" row shows on expanded groups (M5 setting). */
val showAddSubtaskRow: Boolean = true,
/** Whether a real list uses the bottom quick-add bar instead of the FAB. */
val bottomAddBar: Boolean = false,
) : TaskListUiState
}
/**
* The tasks for one [TaskFilter]. Call [bind] with the filter the screen shows;
* the list re-emits live as the provider changes. Actions are fire-and-forget —
* the resulting provider change flows back through the list automatically.
*/
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class TaskListViewModel @Inject constructor(
private val repository: TasksRepository,
private val settingsPrefs: SettingsPrefs,
) : ViewModel() {
private val filter = MutableStateFlow<TaskFilter?>(null)
/** Tasks swiped to delete but not yet committed — hidden until the undo lapses. */
private val pendingDeletes = MutableStateFlow<Set<Long>>(emptySet())
val state: StateFlow<TaskListUiState> =
filter.filterNotNull()
.flatMapLatest { f ->
val tasks = repository.tasks(f)
val content: kotlinx.coroutines.flow.Flow<TaskListUiState> = when (f) {
is TaskFilter.OfList ->
combine(tasks, repository.taskLists()) { list, lists ->
TaskListUiState.Content(list, lists.firstOrNull { it.id == f.listId }?.name)
}
is TaskFilter.Smart ->
tasks.map { TaskListUiState.Content(it) }
}
// Drop rows pending an undoable delete so the row vanishes on swipe
// while the actual provider delete waits for the snackbar to commit,
// and fold in the live UI settings (add-subtask row, bottom add bar).
val uiPrefs = settingsPrefs.settings.map { it.showAddSubtaskRow to it.bottomAddBar }
combine(content, pendingDeletes, uiPrefs) { st, pending, (showRow, bottomBar) ->
if (st is TaskListUiState.Content) {
st.copy(
tasks = st.tasks.filter { it.taskId !in pending },
showAddSubtaskRow = showRow,
bottomAddBar = bottomBar,
)
} else {
st
}
}
.onStart { emit(TaskListUiState.Loading) }
.catch { emit(TaskListUiState.Failure) }
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TaskListUiState.Loading)
/**
* Parents the screen has expanded whose children it can't pull from the list
* itself — i.e. smart lists, where the date filter drops a parent's subtasks.
* For each we observe the full child set live (below), so the group can expand.
*/
private val lazyChildParents = MutableStateFlow<Set<Long>>(emptySet())
/**
* The on-demand child sets, keyed by parent id, kept live. Empty for real
* lists (the screen reports no parents there, since it already holds every
* child) so no redundant queries run.
*/
val lazyChildren: StateFlow<Map<Long, List<Task>>> =
lazyChildParents
.flatMapLatest { ids ->
if (ids.isEmpty()) {
flowOf(emptyMap())
} else {
combine(ids.map { id -> repository.subtasks(id).map { id to it } }) { it.toMap() }
}
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())
/** The screen reports which expanded parents need their children fetched. */
fun setLazyChildParents(ids: Set<Long>) { lazyChildParents.value = ids }
fun bind(taskFilter: TaskFilter) { filter.value = taskFilter }
fun toggleComplete(task: Task) = viewModelScope.launch {
runCatching { repository.setCompleted(task.taskId, !task.isCompleted) }
}
/** Swipe-delete: hide the row now; the screen's snackbar commits or restores it. */
fun markPendingDelete(task: Task) {
pendingDeletes.value = pendingDeletes.value + task.taskId
}
/** Undo — stop hiding the task; it returns to the list. */
fun undoDelete(taskId: Long) {
pendingDeletes.value = pendingDeletes.value - taskId
}
/**
* Commit the delete once the undo window passes. The id stays in the pending
* set: the task is gone from the provider so it can't reappear, and removing
* it could briefly un-hide the row before the provider change propagates.
*/
fun commitDelete(taskId: Long) = viewModelScope.launch {
if (taskId !in pendingDeletes.value) return@launch
runCatching { repository.deleteTask(taskId) }
}
/** Inline "add task" — title only, into [listId]. */
fun quickAdd(title: String, listId: Long) = viewModelScope.launch {
if (title.isBlank() || listId <= 0L) return@launch
runCatching { repository.createTask(TaskForm(title = title, listId = listId)) }
}
/** Inline "add subtask" from an expanded list group — files it under [parent]. */
fun quickAddSubtask(parent: Task, title: String) = viewModelScope.launch {
if (title.isBlank() || parent.listId <= 0L) return@launch
runCatching {
repository.createTask(
TaskForm(title = title.trim(), listId = parent.listId, parentId = parent.taskId),
)
}
}
}

View File

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.floret.ui.theme package de.jeanlucmakiola.agendula.ui.theme
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
@@ -12,14 +12,14 @@ import androidx.compose.ui.graphics.Color
* *
* Placeholder until branding lands; refine alongside the launcher icon. * Placeholder until branding lands; refine alongside the launcher icon.
*/ */
val FloretSeed: Color = Color(0xFF7A5C6B) val AgendulaSeed: Color = Color(0xFF7A5C6B)
/** /**
* Fallback light scheme for devices without dynamic color (API < 31) or when * Fallback light scheme for devices without dynamic color (API < 31) or when
* the user disables it. Dynamic color (API 31+) is the real path; these are a * the user disables it. Dynamic color (API 31+) is the real path; these are a
* hand-picked approximation of the tonal palette generated from [FloretSeed]. * hand-picked approximation of the tonal palette generated from [AgendulaSeed].
*/ */
val FloretLightFallback = lightColorScheme( val AgendulaLightFallback = lightColorScheme(
primary = Color(0xFF6A4E5B), primary = Color(0xFF6A4E5B),
onPrimary = Color(0xFFFFFFFF), onPrimary = Color(0xFFFFFFFF),
primaryContainer = Color(0xFFF6D9E5), primaryContainer = Color(0xFFF6D9E5),
@@ -32,7 +32,7 @@ val FloretLightFallback = lightColorScheme(
onSurface = Color(0xFF1F1A1C), onSurface = Color(0xFF1F1A1C),
) )
val FloretDarkFallback = darkColorScheme( val AgendulaDarkFallback = darkColorScheme(
primary = Color(0xFFE2BAC9), primary = Color(0xFFE2BAC9),
onPrimary = Color(0xFF402533), onPrimary = Color(0xFF402533),
primaryContainer = Color(0xFF583B49), primaryContainer = Color(0xFF583B49),

View File

@@ -0,0 +1,28 @@
package de.jeanlucmakiola.agendula.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import de.jeanlucmakiola.floret.identity.FloretExpressiveTheme
/**
* Agendula's theme: the family's [FloretExpressiveTheme] machinery (dynamic
* colour on API 31+, system light/dark, the standard motion scheme) fed with
* Agendula's own identity — its seed-derived fallback schemes
* ([AgendulaLightFallback] / [AgendulaDarkFallback]) and [AgendulaTypography].
* The mechanics live in floret-kit; the look stays here.
*/
@Composable
fun AgendulaTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit,
) {
FloretExpressiveTheme(
lightScheme = AgendulaLightFallback,
darkScheme = AgendulaDarkFallback,
darkTheme = darkTheme,
dynamicColor = dynamicColor,
typography = AgendulaTypography,
content = content,
)
}

View File

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.floret.ui.theme package de.jeanlucmakiola.agendula.ui.theme
import androidx.compose.material3.Typography import androidx.compose.material3.Typography
@@ -7,4 +7,4 @@ import androidx.compose.material3.Typography
* land in a later UI-design iteration; the defaults keep the M0 foundation lean * land in a later UI-design iteration; the defaults keep the M0 foundation lean
* (same posture as Calendula's scaffolding). * (same posture as Calendula's scaffolding).
*/ */
val FloretTypography = Typography() val AgendulaTypography = Typography()

View File

@@ -1,69 +0,0 @@
package de.jeanlucmakiola.floret
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.floret.data.demo.DemoSeeder
import de.jeanlucmakiola.floret.data.prefs.ThemeMode
import de.jeanlucmakiola.floret.ui.RootScreen
import de.jeanlucmakiola.floret.ui.settings.SettingsViewModel
import de.jeanlucmakiola.floret.ui.theme.FloretTheme
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Single activity. The theme follows [SettingsViewModel]; [RootScreen] is the
* (replaceable) functional scaffold over the real data layer. Task-detail intent
* routing for reminder taps lands with the full UI.
*/
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@Inject lateinit var demoSeeder: DemoSeeder
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
// Debug-only sample data: `am start ... --ez floret_seed true`. Seeds a
// local (non-syncing) demo list once; no-op without the extra.
if (BuildConfig.DEBUG && intent.getBooleanExtra(EXTRA_SEED, false)) {
lifecycleScope.launch { runCatching { demoSeeder.seed() } }
}
setContent {
val settingsViewModel: SettingsViewModel = hiltViewModel()
val ui by settingsViewModel.state.collectAsStateWithLifecycle()
val darkTheme = when (ui.settings.themeMode) {
ThemeMode.SYSTEM -> isSystemInDarkTheme()
ThemeMode.LIGHT -> false
ThemeMode.DARK -> true
}
FloretTheme(darkTheme = darkTheme, dynamicColor = ui.settings.dynamicColor) {
RootScreen(modifier = Modifier.fillMaxSize())
}
}
}
companion object {
const val EXTRA_TASK_ID = "de.jeanlucmakiola.floret.extra.TASK_ID"
private const val EXTRA_SEED = "floret_seed"
/** Opens the app focused on a task (reminder taps). Routing lands with the UI. */
fun taskIntent(context: Context, taskId: Long): Intent =
Intent(context, MainActivity::class.java).apply {
putExtra(EXTRA_TASK_ID, taskId)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
}
}

View File

@@ -1,55 +0,0 @@
package de.jeanlucmakiola.floret.data.prefs
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
enum class ThemeMode { SYSTEM, LIGHT, DARK }
data class Settings(
val themeMode: ThemeMode = ThemeMode.SYSTEM,
val dynamicColor: Boolean = true,
/** The list a new task defaults to; `null` = first available. */
val defaultListId: Long? = null,
/** Default minutes before due to remind; 0 = at due time. */
val reminderLeadMinutes: Int = 0,
)
/** App preferences, backed by DataStore. Mirrors Calendula's prefs shape. */
@Singleton
class SettingsPrefs @Inject constructor(
private val dataStore: DataStore<Preferences>,
) {
val settings: Flow<Settings> = dataStore.data.map { p ->
Settings(
themeMode = p[THEME_MODE]?.let { runCatching { ThemeMode.valueOf(it) }.getOrNull() }
?: ThemeMode.SYSTEM,
dynamicColor = p[DYNAMIC_COLOR] ?: true,
defaultListId = p[DEFAULT_LIST_ID]?.takeIf { it > 0 },
reminderLeadMinutes = p[REMINDER_LEAD] ?: 0,
)
}
suspend fun setThemeMode(mode: ThemeMode) = dataStore.edit { it[THEME_MODE] = mode.name }
suspend fun setDynamicColor(enabled: Boolean) = dataStore.edit { it[DYNAMIC_COLOR] = enabled }
suspend fun setDefaultListId(id: Long?) = dataStore.edit {
if (id == null) it.remove(DEFAULT_LIST_ID) else it[DEFAULT_LIST_ID] = id
}
suspend fun setReminderLeadMinutes(minutes: Int) = dataStore.edit { it[REMINDER_LEAD] = minutes }
private companion object {
val THEME_MODE = stringPreferencesKey("theme_mode")
val DYNAMIC_COLOR = booleanPreferencesKey("dynamic_color")
val DEFAULT_LIST_ID = longPreferencesKey("default_list_id")
val REMINDER_LEAD = intPreferencesKey("reminder_lead_minutes")
}
}

View File

@@ -1,31 +0,0 @@
package de.jeanlucmakiola.floret.data.tasks
import de.jeanlucmakiola.floret.domain.Task
import de.jeanlucmakiola.floret.domain.TaskDetail
import de.jeanlucmakiola.floret.domain.TaskFilter
import de.jeanlucmakiola.floret.domain.TaskForm
import de.jeanlucmakiola.floret.domain.TaskList
import kotlinx.coroutines.flow.Flow
/** Whether Floret can use the tasks provider right now. Drives onboarding. */
enum class ProviderStatus { READY, NEEDS_PERMISSION, NO_PROVIDER }
/**
* The single entry point the UI layer uses. Flows re-emit automatically when the
* provider changes (our writes *and* external sync like DAVx5), via the
* data source's ContentObserver.
*/
interface TasksRepository {
fun taskLists(): Flow<List<TaskList>>
fun tasks(filter: TaskFilter): Flow<List<Task>>
fun taskDetail(taskId: Long): Flow<TaskDetail?>
suspend fun createTask(form: TaskForm): Long
suspend fun updateTask(taskId: Long, form: TaskForm)
suspend fun setCompleted(taskId: Long, completed: Boolean)
suspend fun deleteTask(taskId: Long)
suspend fun createLocalList(name: String, color: Int): Long
/** Synchronous snapshot for the permission/onboarding gate. */
fun providerStatus(): ProviderStatus
}

View File

@@ -1,18 +0,0 @@
package de.jeanlucmakiola.floret.domain
import java.time.ZoneId
import kotlin.time.Instant
/** Local-day boundaries used by the smart-list predicates. Pure + testable. */
object DayWindow {
/** Returns `[startOfToday, startOfTomorrow)` in [zone] for the instant [now]. */
fun today(now: Instant, zone: ZoneId): Pair<Instant, Instant> {
val date = java.time.Instant.ofEpochMilli(now.toEpochMilliseconds())
.atZone(zone)
.toLocalDate()
val start = date.atStartOfDay(zone).toInstant().toEpochMilli()
val end = date.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli()
return Instant.fromEpochMilliseconds(start) to Instant.fromEpochMilliseconds(end)
}
}

View File

@@ -1,32 +0,0 @@
package de.jeanlucmakiola.floret.domain
import kotlin.time.Instant
/**
* A validated create/edit form. Kept free of Android types so ViewModels and
* tests can build and validate it on the JVM. The data layer turns it into
* provider ContentValues ([de.jeanlucmakiola.floret.data.tasks.TaskWriteMapper]).
*/
data class TaskForm(
val title: String,
val listId: Long,
val description: String? = null,
val start: Instant? = null,
val due: Instant? = null,
val isAllDay: Boolean = false,
val priority: Priority = Priority.NONE,
val parentId: Long? = null,
/** Minutes before [due] to fire a reminder; `null` = no reminder. */
val reminderMinutesBeforeDue: Int? = null,
) {
fun validate(): Set<TaskFormError> = buildSet {
if (title.isBlank()) add(TaskFormError.BLANK_TITLE)
if (listId <= 0L) add(TaskFormError.NO_LIST)
if (start != null && due != null && due < start) add(TaskFormError.DUE_BEFORE_START)
if (reminderMinutesBeforeDue != null && due == null) add(TaskFormError.REMINDER_WITHOUT_DUE)
}
val isValid: Boolean get() = validate().isEmpty()
}
enum class TaskFormError { BLANK_TITLE, NO_LIST, DUE_BEFORE_START, REMINDER_WITHOUT_DUE }

View File

@@ -1,116 +0,0 @@
package de.jeanlucmakiola.floret.ui.common
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* Position of a row within a grouped list (the Android-15 settings pattern): a
* run of rows shares one rounded container, full corners at the group's outer
* edges and small corners between, separated by small gaps. Copied from
* Calendula so the two apps read as one family.
*/
enum class Position { Top, Middle, Bottom, Alone }
fun positionOf(index: Int, count: Int): Position = when {
count <= 1 -> Position.Alone
index == 0 -> Position.Top
index == count - 1 -> Position.Bottom
else -> Position.Middle
}
/**
* One row in a grouped list: an M3 [ListItem] over a tonal [Surface] whose
* corner radii come from its [position] (so a run of rows reads as a single
* rounded card). Corners morph further on press — the expressive shape play.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GroupedRow(
title: String,
position: Position,
modifier: Modifier = Modifier,
summary: String? = null,
selected: Boolean = false,
minHeight: Dp = 72.dp,
leading: @Composable (() -> Unit)? = null,
trailing: @Composable (() -> Unit)? = null,
onClick: (() -> Unit)? = null,
) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val full by animateDpAsState(if (pressed) 36.dp else 22.dp, label = "fullCorner")
val small by animateDpAsState(if (pressed) 36.dp else 6.dp, label = "smallCorner")
val shape = when (position) {
Position.Alone -> RoundedCornerShape(full)
Position.Top -> RoundedCornerShape(
topStart = full, topEnd = full, bottomStart = small, bottomEnd = small,
)
Position.Middle -> RoundedCornerShape(small)
Position.Bottom -> RoundedCornerShape(
topStart = small, topEnd = small, bottomStart = full, bottomEnd = full,
)
}
val gap = when (position) {
Position.Top, Position.Middle -> Modifier.padding(bottom = 2.dp)
Position.Bottom, Position.Alone -> Modifier
}
val itemColors = if (selected) {
ListItemDefaults.colors(
containerColor = Color.Transparent,
headlineColor = MaterialTheme.colorScheme.onSecondaryContainer,
leadingIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
supportingColor = MaterialTheme.colorScheme.onSecondaryContainer,
trailingIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
)
} else {
ListItemDefaults.colors(containerColor = Color.Transparent)
}
val item: @Composable () -> Unit = {
ListItem(
headlineContent = { Text(title) },
supportingContent = summary?.let { text -> { Text(text) } },
leadingContent = leading,
trailingContent = trailing,
colors = itemColors,
modifier = Modifier.heightIn(min = minHeight),
)
}
val base = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.then(gap)
val containerColor = if (selected) {
MaterialTheme.colorScheme.secondaryContainer
} else {
MaterialTheme.colorScheme.surfaceContainerHigh
}
if (onClick != null) {
Surface(
onClick = onClick,
color = containerColor,
shape = shape,
interactionSource = interaction,
modifier = base,
) { item() }
} else {
Surface(color = containerColor, shape = shape, modifier = base) { item() }
}
}

View File

@@ -1,144 +0,0 @@
package de.jeanlucmakiola.floret.ui.edit
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.floret.data.prefs.SettingsPrefs
import de.jeanlucmakiola.floret.data.reminders.ReminderScheduler
import de.jeanlucmakiola.floret.data.tasks.TasksRepository
import de.jeanlucmakiola.floret.domain.Priority
import de.jeanlucmakiola.floret.domain.TaskForm
import de.jeanlucmakiola.floret.domain.TaskFormError
import de.jeanlucmakiola.floret.domain.TaskList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.time.Instant
/**
* Editable form state for creating or editing a task. The screen renders these
* fields and calls the `on*` setters; [save] validates, writes, re-syncs
* reminders, and flips [TaskEditUiState.saved] for the screen to navigate back.
*/
data class TaskEditUiState(
val loading: Boolean = true,
val isNew: Boolean = true,
val title: String = "",
val description: String = "",
val listId: Long? = null,
val start: Instant? = null,
val due: Instant? = null,
val isAllDay: Boolean = false,
val priority: Priority = Priority.NONE,
val parentId: Long? = null,
val reminderMinutesBeforeDue: Int? = null,
val lists: List<TaskList> = emptyList(),
val errors: Set<TaskFormError> = emptySet(),
val saveFailed: Boolean = false,
val saved: Boolean = false,
)
@HiltViewModel
class TaskEditViewModel @Inject constructor(
private val repository: TasksRepository,
private val settingsPrefs: SettingsPrefs,
private val reminderScheduler: ReminderScheduler,
) : ViewModel() {
private val _state = MutableStateFlow(TaskEditUiState())
val state: StateFlow<TaskEditUiState> = _state.asStateFlow()
private var editingTaskId: Long? = null
/** Start a fresh task, optionally pre-selecting a list / parent. */
fun bindNew(presetListId: Long? = null, parentId: Long? = null) {
editingTaskId = null
viewModelScope.launch {
val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() }
val defaultList = presetListId
?: settingsPrefs.settings.first().defaultListId
?: lists.firstOrNull { !it.isLocal }?.id
?: lists.firstOrNull()?.id
_state.value = TaskEditUiState(
loading = false,
isNew = true,
listId = defaultList,
parentId = parentId,
lists = lists,
)
}
}
/** Load an existing task for editing. */
fun bindEdit(taskId: Long) {
editingTaskId = taskId
viewModelScope.launch {
val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() }
val task = runCatching { repository.taskDetail(taskId).first()?.task }.getOrNull()
if (task == null) {
_state.value = _state.value.copy(loading = false, lists = lists)
return@launch
}
_state.value = TaskEditUiState(
loading = false,
isNew = false,
title = task.title,
description = task.description.orEmpty(),
listId = task.listId,
start = task.start,
due = task.due,
isAllDay = task.isAllDay,
priority = task.priority,
parentId = task.parentId,
lists = lists,
)
}
}
fun onTitleChange(value: String) = update { it.copy(title = value, errors = emptySet()) }
fun onDescriptionChange(value: String) = update { it.copy(description = value) }
fun onListChange(listId: Long) = update { it.copy(listId = listId, errors = emptySet()) }
fun onStartChange(value: Instant?) = update { it.copy(start = value) }
fun onDueChange(value: Instant?) = update { it.copy(due = value) }
fun onAllDayChange(value: Boolean) = update { it.copy(isAllDay = value) }
fun onPriorityChange(value: Priority) = update { it.copy(priority = value) }
fun onReminderChange(minutesBeforeDue: Int?) = update { it.copy(reminderMinutesBeforeDue = minutesBeforeDue) }
fun save() {
val current = _state.value
val form = TaskForm(
title = current.title,
listId = current.listId ?: 0L,
description = current.description.ifBlank { null },
start = current.start,
due = current.due,
isAllDay = current.isAllDay,
priority = current.priority,
parentId = current.parentId,
reminderMinutesBeforeDue = current.reminderMinutesBeforeDue,
)
val errors = form.validate()
if (errors.isNotEmpty()) {
_state.value = current.copy(errors = errors)
return
}
viewModelScope.launch {
runCatching {
val id = editingTaskId
if (id == null) repository.createTask(form) else repository.updateTask(id, form)
reminderScheduler.sync()
}.onSuccess {
_state.value = _state.value.copy(saved = true, saveFailed = false)
}.onFailure {
_state.value = _state.value.copy(saveFailed = true)
}
}
}
private inline fun update(block: (TaskEditUiState) -> TaskEditUiState) {
_state.value = block(_state.value)
}
}

View File

@@ -1,236 +0,0 @@
package de.jeanlucmakiola.floret.ui.lists
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ListAlt
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.ErrorOutline
import androidx.compose.material.icons.rounded.Today
import androidx.compose.material.icons.rounded.Upcoming
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
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.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.floret.R
import de.jeanlucmakiola.floret.domain.SmartList
import de.jeanlucmakiola.floret.domain.TaskFilter
import de.jeanlucmakiola.floret.ui.common.GroupedRow
import de.jeanlucmakiola.floret.ui.common.ListColorChip
import de.jeanlucmakiola.floret.ui.common.positionOf
/**
* Home: smart lists (Today / Overdue / Upcoming / All) as tonal cards with live
* counts, then the user's lists grouped by account. Tapping a card or row opens
* that task list; the FAB starts a new task.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ListsScreen(
onOpenFilter: (TaskFilter) -> Unit,
onNewTask: () -> Unit,
modifier: Modifier = Modifier,
viewModel: ListsViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
MediumTopAppBar(
title = { Text(stringResource(R.string.app_name)) },
scrollBehavior = scrollBehavior,
)
},
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = onNewTask,
icon = { Icon(Icons.Rounded.Add, contentDescription = null) },
text = { Text(stringResource(R.string.new_task)) },
)
},
) { inner ->
when (val s = state) {
ListsUiState.Loading -> Unit // brief; avoids a flash before first emission
ListsUiState.Failure -> CenteredMessage(stringResource(R.string.lists_failure), inner)
is ListsUiState.Content -> ListsContent(s, inner, onOpenFilter)
}
}
}
@Composable
private fun ListsContent(
state: ListsUiState.Content,
inner: PaddingValues,
onOpenFilter: (TaskFilter) -> Unit,
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
top = inner.calculateTopPadding(),
bottom = inner.calculateBottomPadding() + 96.dp,
),
) {
item { SmartGrid(state.smartCounts, onOpenFilter) }
if (state.groups.isEmpty()) {
item { CenteredMessage(stringResource(R.string.lists_empty), PaddingValues(top = 24.dp)) }
} else {
item { SectionHeader(stringResource(R.string.lists_header)) }
state.groups.forEach { group ->
item(key = "acct-${group.accountName}") { AccountHeader(group.accountName) }
itemsIndexed(group.lists, key = { _, o -> o.list.id }) { index, overview ->
GroupedRow(
title = overview.list.name,
position = positionOf(index, group.lists.size),
minHeight = 72.dp,
leading = { ListColorChip(overview.list.color) },
trailing = {
if (overview.openCount > 0) {
Text(
overview.openCount.toString(),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
onClick = { onOpenFilter(TaskFilter.OfList(overview.list.id)) },
)
}
}
}
}
}
@Composable
private fun SmartGrid(counts: List<SmartCount>, onOpenFilter: (TaskFilter) -> Unit) {
Column(
modifier = Modifier.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
counts.chunked(2).forEach { row ->
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
row.forEach { smart ->
SmartCard(
count = smart,
modifier = Modifier.weight(1f),
onClick = { onOpenFilter(TaskFilter.Smart(smart.smart)) },
)
}
if (row.size == 1) Spacer(Modifier.weight(1f))
}
}
}
}
private data class SmartStyle(val icon: ImageVector, val labelRes: Int, val container: Color, val onContainer: Color)
@Composable
private fun smartStyle(smart: SmartList): SmartStyle {
val colors = MaterialTheme.colorScheme
return when (smart) {
SmartList.TODAY -> SmartStyle(Icons.Rounded.Today, R.string.smart_today, colors.primaryContainer, colors.onPrimaryContainer)
SmartList.OVERDUE -> SmartStyle(Icons.Rounded.ErrorOutline, R.string.smart_overdue, colors.errorContainer, colors.onErrorContainer)
SmartList.UPCOMING -> SmartStyle(Icons.Rounded.Upcoming, R.string.smart_upcoming, colors.tertiaryContainer, colors.onTertiaryContainer)
else -> SmartStyle(Icons.AutoMirrored.Rounded.ListAlt, R.string.smart_all, colors.secondaryContainer, colors.onSecondaryContainer)
}
}
/**
* Clean tonal tile. The expressive touch is subtle: rounded corners gently morph
* rounder on press (the same quiet shape behaviour as the lists), not a loud
* silhouette.
*/
@Composable
private fun SmartCard(count: SmartCount, modifier: Modifier = Modifier, onClick: () -> Unit) {
val style = smartStyle(count.smart)
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val corner by animateDpAsState(if (pressed) 34.dp else 22.dp, label = "smartCorner")
Surface(
onClick = onClick,
shape = RoundedCornerShape(corner),
color = style.container,
contentColor = style.onContainer,
interactionSource = interaction,
modifier = modifier.height(116.dp),
) {
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.SpaceBetween,
) {
Icon(style.icon, contentDescription = null)
Column {
Text(count.count.toString(), style = MaterialTheme.typography.headlineMedium)
Text(stringResource(style.labelRes), style = MaterialTheme.typography.labelLarge)
}
}
}
}
@Composable
private fun SectionHeader(text: String) {
Text(
text = text,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 4.dp),
)
}
@Composable
private fun AccountHeader(text: String) {
Text(
text = text,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 12.dp, bottom = 4.dp),
)
}
@Composable
private fun CenteredMessage(text: String, inner: PaddingValues) {
Box(
modifier = Modifier.fillMaxSize().padding(inner).padding(32.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = text,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}

View File

@@ -1,73 +0,0 @@
package de.jeanlucmakiola.floret.ui.lists
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.floret.data.tasks.TasksRepository
import de.jeanlucmakiola.floret.domain.DayWindow
import de.jeanlucmakiola.floret.domain.SmartList
import de.jeanlucmakiola.floret.domain.Task
import de.jeanlucmakiola.floret.domain.TaskFilter
import de.jeanlucmakiola.floret.domain.TaskFiltering
import de.jeanlucmakiola.floret.domain.TaskList
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import java.time.ZoneId
import javax.inject.Inject
import kotlin.time.Clock
data class ListOverview(val list: TaskList, val openCount: Int)
data class AccountGroup(val accountName: String, val lists: List<ListOverview>)
data class SmartCount(val smart: SmartList, val count: Int)
sealed interface ListsUiState {
data object Loading : ListsUiState
data object Failure : ListsUiState
data class Content(
val smartCounts: List<SmartCount>,
val groups: List<AccountGroup>,
) : ListsUiState
}
/** The home overview: smart lists with live counts, then user lists by account. */
@HiltViewModel
class ListsViewModel @Inject constructor(
repository: TasksRepository,
) : ViewModel() {
val state: StateFlow<ListsUiState> =
combine(
repository.taskLists(),
repository.tasks(TaskFilter.Smart(SmartList.ALL)),
) { lists, openTasks ->
buildContent(lists, openTasks) as ListsUiState
}.catch { emit(ListsUiState.Failure) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListsUiState.Loading)
private fun buildContent(lists: List<TaskList>, openTasks: List<Task>): ListsUiState.Content {
val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault())
fun count(smart: SmartList) =
openTasks.count { TaskFiltering.matches(it, TaskFilter.Smart(smart), todayStart, todayEnd) }
val smartCounts = listOf(
SmartCount(SmartList.TODAY, count(SmartList.TODAY)),
SmartCount(SmartList.OVERDUE, count(SmartList.OVERDUE)),
SmartCount(SmartList.UPCOMING, count(SmartList.UPCOMING)),
SmartCount(SmartList.ALL, openTasks.size),
)
val openByList = openTasks.groupingBy { it.listId }.eachCount()
val groups = lists
.groupBy { it.accountName }
.map { (account, accountLists) ->
AccountGroup(
accountName = account,
lists = accountLists.map { ListOverview(it, openByList[it.id] ?: 0) },
)
}
.sortedBy { it.accountName.lowercase() }
return ListsUiState.Content(smartCounts, groups)
}
}

View File

@@ -1,67 +0,0 @@
package de.jeanlucmakiola.floret.ui.tasklist
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.floret.data.tasks.TasksRepository
import de.jeanlucmakiola.floret.domain.Task
import de.jeanlucmakiola.floret.domain.TaskFilter
import de.jeanlucmakiola.floret.domain.TaskForm
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
sealed interface TaskListUiState {
data object Loading : TaskListUiState
data object Failure : TaskListUiState
data class Content(val tasks: List<Task>) : TaskListUiState
}
/**
* The tasks for one [TaskFilter]. Call [bind] with the filter the screen shows;
* the list re-emits live as the provider changes. Actions are fire-and-forget —
* the resulting provider change flows back through the list automatically.
*/
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class TaskListViewModel @Inject constructor(
private val repository: TasksRepository,
) : ViewModel() {
private val filter = MutableStateFlow<TaskFilter?>(null)
val state: StateFlow<TaskListUiState> =
filter.filterNotNull()
.flatMapLatest { f ->
repository.tasks(f)
.map<List<Task>, TaskListUiState> { TaskListUiState.Content(it) }
.onStart { emit(TaskListUiState.Loading) }
.catch { emit(TaskListUiState.Failure) }
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TaskListUiState.Loading)
fun bind(taskFilter: TaskFilter) { filter.value = taskFilter }
fun toggleComplete(task: Task) = viewModelScope.launch {
runCatching { repository.setCompleted(task.taskId, !task.isCompleted) }
}
fun delete(task: Task) = viewModelScope.launch {
runCatching { repository.deleteTask(task.taskId) }
}
/** Inline "add task" — title only, into [listId]. */
fun quickAdd(title: String, listId: Long) = viewModelScope.launch {
if (title.isBlank() || listId <= 0L) return@launch
runCatching { repository.createTask(TaskForm(title = title, listId = listId)) }
}
}

View File

@@ -1,48 +0,0 @@
package de.jeanlucmakiola.floret.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialExpressiveTheme
import androidx.compose.material3.MotionScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
/**
* App theme. Honors:
* - System light/dark.
* - Dynamic Color on API 31+, else falls back to the hand-tuned scheme
* derived from [FloretSeed].
*
* Mirrors Calendula's theme: MaterialExpressiveTheme routes all component +
* custom motion through MaterialTheme.motionScheme. STANDARD over expressive()
* is the same deliberate choice — spring choreography without the overshoot.
*
* A Settings screen can later override dynamicColor and theme mode; the M0
* foundation just follows the system.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun FloretTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit,
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val ctx = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(ctx) else dynamicLightColorScheme(ctx)
}
darkTheme -> FloretDarkFallback
else -> FloretLightFallback
}
MaterialExpressiveTheme(
colorScheme = colorScheme,
typography = FloretTypography,
motionScheme = MotionScheme.standard(),
content = content,
)
}

View File

@@ -1,22 +1,89 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- <!--
Floret launcher icon foreground — PLACEHOLDER. Agendula launcher icon foreground.
A simple rounded check mark inside the 108dp adaptive-icon canvas Converted from design/icon/agendula_icon.svg (512x512 viewport): a rounded
(72dp safe zone). Deliberately not Calendula's calendar mark, so the two line-art task card with a check mark, plus a small Calendula bloom badge in
apps never look alike. Replace with real branding (a stylized floret) the bottom-right corner — the sibling of Calendula's calendar mark.
when the design lands — see docs/PLAN.md §9.
Strokes render in off-white (#FAF6F0) over the plum background drawable
(drawable/ic_launcher_background.xml = @color/ic_launcher_background, the
hue-rotated counterpart of Calendula's slate). The same vector fills the
<monochrome> slot so Android 13+ themed-icon launchers can recolour it.
Centering / scale:
- Scale 0.66 about the canvas centre (matching Calendula's ~2.8dp stroke
weight and generous padding).
- The eye centres on the task CARD, not the card+bloom bounding box (the
bloom is a small badge that overhangs the bottom-right). So vertically we
centre the card itself: its geometric centre is y=242.76, so we pivot the
Y-scale there and translate +13.24 to drop that centre onto the canvas
centre (256). Horizontally the card already sits centred, so X scales
about 256 untouched.
--> -->
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp" android:width="108dp"
android:height="108dp" android:height="108dp"
android:viewportWidth="108" android:viewportWidth="512"
android:viewportHeight="108"> android:viewportHeight="512">
<path <group
android:fillColor="#00000000" android:pivotX="256"
android:strokeColor="#FFFFFF" android:pivotY="242.76"
android:strokeWidth="9" android:scaleX="0.66"
android:strokeLineCap="round" android:scaleY="0.66"
android:strokeLineJoin="round" android:translateY="13.24">
android:pathData="M36,55 l13,13 l25,-27" /> <!-- Task card (rounded square, opening at the bottom-right for the bloom) -->
<path
android:strokeColor="#FFFAF6F0"
android:strokeWidth="20"
android:strokeMiterLimit="12"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M370.207 254.345V168.276C370.207 144.508 350.939 125.241 327.172 125.241H178.207C154.439 125.241 135.172 144.508 135.172 168.276V317.241C135.172 341.008 154.439 360.276 178.207 360.276H269.241" />
<!-- Check mark -->
<path
android:strokeColor="#FFFAF6F0"
android:strokeWidth="20"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M193 245.569L231.822 287L320 199" />
<!-- Calendula bloom: petals around the centre -->
<path
android:strokeColor="#FFFAF6F0"
android:strokeWidth="16"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M346.672 323.721C356.301 323.721 364.107 312.367 364.107 298.361C364.107 284.354 356.301 273 346.672 273C337.042 273 329.236 284.354 329.236 298.361C329.236 312.367 337.042 323.721 346.672 323.721Z" />
<path
android:strokeColor="#FFFAF6F0"
android:strokeWidth="16"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M355.716 330.293C358.692 339.451 371.903 343.366 385.224 339.038C398.544 334.71 406.931 323.777 403.955 314.619C400.98 305.461 387.769 301.546 374.448 305.874C361.127 310.202 352.741 321.135 355.716 330.293Z" />
<path
android:strokeColor="#FFFAF6F0"
android:strokeWidth="16"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M352.262 340.926C344.471 346.586 344.83 360.36 353.063 371.691C361.295 383.022 374.284 387.62 382.075 381.96C389.865 376.3 389.506 362.526 381.274 351.194C373.041 339.863 360.052 335.266 352.262 340.926Z" />
<path
android:strokeColor="#FFFAF6F0"
android:strokeWidth="16"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M341.082 340.926C333.291 335.266 320.302 339.863 312.069 351.194C303.837 362.526 303.478 376.3 311.268 381.96C319.059 387.62 332.048 383.022 340.28 371.691C348.513 360.36 348.872 346.585 341.082 340.926Z" />
<path
android:strokeColor="#FFFAF6F0"
android:strokeWidth="16"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M337.627 330.293C340.602 321.135 332.216 310.202 318.895 305.874C305.574 301.546 292.363 305.461 289.388 314.619C286.412 323.777 294.799 334.71 308.119 339.038C321.44 343.366 334.651 339.451 337.627 330.293Z" />
<!-- Bloom centre -->
<path
android:strokeColor="#FFFAF6F0"
android:strokeWidth="16"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M346.672 342.742C351.924 342.742 356.182 338.484 356.182 333.232C356.182 327.979 351.924 323.721 346.672 323.721C341.419 323.721 337.161 327.979 337.161 333.232C337.161 338.484 341.419 342.742 346.672 342.742Z" />
</group>
</vector> </vector>

View File

@@ -1,9 +1,122 @@
<resources> <resources>
<string name="app_name">Floret</string> <string name="app_name">Agendula</string>
<string name="app_tagline">A modern Material 3 Expressive task app.\nComing into bloom.</string> <string name="app_tagline">A modern Material 3 Expressive task app.\nComing into bloom.</string>
<!-- Generic --> <!-- Generic -->
<string name="task_untitled">Untitled task</string> <string name="task_untitled">Untitled task</string>
<string name="back">Back</string>
<string name="save">Save</string>
<string name="delete">Delete</string>
<!-- Task list -->
<string name="tasks_title">Tasks</string>
<string name="task_list_empty">No tasks here yet.</string>
<string name="task_list_failure">Could not load these tasks.</string>
<string name="smart_no_date">No date</string>
<string name="smart_completed">Completed</string>
<!-- Task detail -->
<string name="task_detail_title">Task</string>
<string name="task_detail_not_found">This task no longer exists.</string>
<string name="edit">Edit</string>
<!-- Task edit -->
<string name="edit_task_title">Edit task</string>
<string name="new_task_title">New task</string>
<string name="field_title">Title</string>
<string name="field_description">Description</string>
<string name="edit_save_failed">Could not save. Try again.</string>
<!-- Task list: inline add + swipe -->
<string name="add_task_hint">Add a task</string>
<string name="add_subtask_hint">Add a subtask</string>
<string name="cd_add_task">Add task</string>
<string name="cd_complete">Complete</string>
<string name="cd_reopen">Reopen</string>
<string name="task_deleted">Deleted</string>
<string name="undo">Undo</string>
<string name="cd_expand_subtasks">Show subtasks</string>
<string name="cd_collapse_subtasks">Hide subtasks</string>
<!-- Sections (due-date buckets) -->
<string name="section_overdue">Overdue</string>
<string name="section_today">Today</string>
<string name="section_upcoming">Upcoming</string>
<string name="section_no_date">No date</string>
<string name="section_completed">Completed</string>
<!-- Priority -->
<string name="priority_none">None</string>
<string name="priority_low">Low</string>
<string name="priority_medium">Medium</string>
<string name="priority_high">High</string>
<!-- Task detail -->
<string name="detail_mark_complete">Mark complete</string>
<string name="detail_mark_incomplete">Mark not complete</string>
<string name="detail_list">List</string>
<string name="detail_due">Due</string>
<string name="detail_start">Starts</string>
<string name="detail_priority">Priority</string>
<string name="detail_progress">Progress</string>
<string name="detail_subtasks">Subtasks</string>
<string name="detail_parent">Parent task</string>
<string name="detail_part_of">Part of</string>
<string name="percent_complete">%1$d%%</string>
<!-- Task edit -->
<string name="edit_list_label">List</string>
<string name="edit_start_label">Starts</string>
<string name="edit_due_label">Due</string>
<string name="edit_all_day">All day</string>
<string name="edit_priority_label">Priority</string>
<string name="edit_progress_label">Progress</string>
<string name="edit_parent_label">Parent task</string>
<string name="edit_parent_none">None (top-level)</string>
<string name="edit_parent_search">Search tasks</string>
<string name="edit_parent_empty">No matching tasks in this list.</string>
<string name="edit_reminder_label">Reminder</string>
<string name="edit_set">Set</string>
<string name="edit_clear">Clear</string>
<string name="edit_title_hint">Task title</string>
<string name="edit_when">When</string>
<string name="edit_more_fields">More fields</string>
<string name="close">Close</string>
<string name="dialog_cancel">Cancel</string>
<!-- Save conflict -->
<string name="edit_conflict_title">Task changed elsewhere</string>
<string name="edit_conflict_body">This task was updated somewhere else (a sync or another app) since you opened it. Overwrite those changes with your edits?</string>
<string name="edit_conflict_overwrite">Overwrite</string>
<!-- Reminder offsets -->
<string name="reminder_none">None</string>
<string name="reminder_at_due">At time of task</string>
<string name="reminder_5_min">5 minutes before</string>
<string name="reminder_10_min">10 minutes before</string>
<string name="reminder_30_min">30 minutes before</string>
<string name="reminder_1_hour">1 hour before</string>
<string name="reminder_1_day">1 day before</string>
<!-- Reminder custom lead time + per-list overrides -->
<string name="reminder_custom">Custom</string>
<string name="reminder_custom_with_value">Custom (%1$s)</string>
<string name="reminder_custom_set">Set</string>
<string name="reminder_custom_amount">Enter an amount</string>
<string name="reminder_use_default">Use default</string>
<string name="reminder_unit_minutes">Minutes</string>
<string name="reminder_unit_hours">Hours</string>
<string name="reminder_unit_days">Days</string>
<string name="reminder_unit_weeks">Weeks</string>
<string name="settings_list_reminders_title">Per-list reminders</string>
<string name="settings_list_reminders_hint">Override the default reminder for individual lists</string>
<string name="settings_list_reminder_inherits">Default (%1$s)</string>
<!-- Form validation -->
<string name="error_blank_title">Enter a title.</string>
<string name="error_no_list">Choose a list.</string>
<string name="error_due_before_start">Due can\'t be before the start.</string>
<string name="error_reminder_without_due">Set a due date to use a reminder.</string>
<!-- Lists overview --> <!-- Lists overview -->
<string name="lists_header">Lists</string> <string name="lists_header">Lists</string>
@@ -16,6 +129,23 @@
<string name="smart_all">All</string> <string name="smart_all">All</string>
<string name="open_count">%1$d open</string> <string name="open_count">%1$d open</string>
<!-- Home: Today progress hero -->
<string name="home_today_progress">%1$d of %2$d done</string>
<string name="home_today_remaining">%1$d left</string>
<string name="home_today_all_done">All done 🎉</string>
<string name="home_today_empty">Nothing due today 🎉</string>
<!-- Home: Upcoming preview -->
<string name="home_upcoming_view_all">View all</string>
<string name="home_due_today">Today</string>
<string name="home_due_tomorrow">Tomorrow</string>
<!-- Home: search -->
<string name="home_search_hint">Search tasks</string>
<string name="home_search_clear">Clear search</string>
<string name="home_search_close">Close search</string>
<string name="home_search_empty">No tasks match “%1$s”</string>
<!-- Reminders --> <!-- Reminders -->
<string name="reminder_due_at">Due %1$s</string> <string name="reminder_due_at">Due %1$s</string>
<string name="reminder_channel_name">Task reminders</string> <string name="reminder_channel_name">Task reminders</string>
@@ -23,10 +153,86 @@
<!-- Provider / permission onboarding --> <!-- Provider / permission onboarding -->
<string name="onboarding_no_provider_title">A tasks app is needed</string> <string name="onboarding_no_provider_title">A tasks app is needed</string>
<string name="onboarding_no_provider_body">Floret shows and edits the tasks stored by OpenTasks or tasks.org, synced by DAVx5. Install one of them to get started.</string> <string name="onboarding_no_provider_body">Agendula shows and edits the tasks stored by OpenTasks or tasks.org, synced by DAVx5. Install one of them to get started.</string>
<string name="onboarding_permission_title">Allow access to your tasks</string> <string name="onboarding_permission_title">Allow access to your tasks</string>
<string name="onboarding_permission_body">Floret needs permission to read and write your tasks. That\'s the only thing it ever asks for.</string> <string name="onboarding_permission_body">Agendula needs permission to read and write your tasks. That\'s the only thing it ever asks for.</string>
<string name="onboarding_permission_button">Grant task access</string> <string name="onboarding_permission_button">Grant task access</string>
<string name="onboarding_install_opentasks">Install OpenTasks</string> <string name="onboarding_install_opentasks">Install OpenTasks</string>
<string name="onboarding_install_tasksorg">Install tasks.org</string> <string name="onboarding_install_tasksorg">Install tasks.org</string>
<!-- Reminder onboarding (one-time, after the provider grant) -->
<string name="reminder_onboarding_title">Never miss what\'s due</string>
<string name="reminder_onboarding_body">Tasks apps don\'t send reminders themselves, so Agendula delivers them for you. Turn them on to get a notification when a task is due.</string>
<string name="reminder_onboarding_enable_button">Enable reminders</string>
<string name="reminder_onboarding_skip_button">Not now</string>
<string name="reminder_benefit_delivery_title">Agendula reminds you</string>
<string name="reminder_benefit_delivery_body">It schedules a notification for each task with a due date.</string>
<string name="reminder_benefit_timing_title">On your schedule</string>
<string name="reminder_benefit_timing_body">Choose how far ahead to be reminded in Settings.</string>
<string name="reminder_benefit_reversible_title">Change it anytime</string>
<string name="reminder_benefit_reversible_body">Turn reminders off whenever you like — it\'s just a switch.</string>
<!-- Settings -->
<string name="settings_title">Settings</string>
<string name="settings_section_appearance">Appearance</string>
<string name="settings_appearance_subtitle">Theme and colour</string>
<string name="settings_section_task_form">Task form</string>
<string name="settings_task_form_subtitle">Default fields, list and subtasks</string>
<string name="settings_reminders_subtitle">Notifications for due tasks</string>
<string name="settings_form_fields_hint">Fields shown by default on a new task. The rest sit behind \"More fields\".</string>
<string name="settings_dynamic_color_unavailable">Available on Android 12 and later</string>
<!-- About -->
<string name="settings_about_author">by Jean-Luc Makiola</string>
<string name="settings_about_source">Source</string>
<string name="settings_license">License</string>
<string name="settings_about_support">Support development</string>
<string name="settings_about_version">Version %1$s</string>
<string name="settings_about_logo_desc">Agendula app icon</string>
<string name="settings_language">App language</string>
<string name="settings_language_auto">System default</string>
<string name="settings_report_problem">Report a problem</string>
<string name="settings_report_problem_hint">Open the issue tracker</string>
<string name="about_source_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/agendula</string>
<string name="crash_report_issue_title">Crash report</string>
<string name="report_issue_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/agendula/issues/new</string>
<string name="about_license_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/agendula/src/branch/main/LICENSE</string>
<string name="about_support_url" translatable="false">https://ko-fi.com/jeanlucmakiola</string>
<string name="settings_theme">Theme</string>
<string name="settings_theme_system">Follow system</string>
<string name="settings_theme_light">Light</string>
<string name="settings_theme_dark">Dark</string>
<string name="settings_dynamic_color">Dynamic colour</string>
<string name="settings_dynamic_color_hint">Use colours from your wallpaper</string>
<string name="settings_section_reminders">Reminders</string>
<string name="settings_reminders">Due reminders</string>
<string name="settings_reminders_hint">Notify me when a task is due</string>
<string name="settings_default_reminder">When to remind</string>
<string name="settings_exact_alarms">Exact timing</string>
<string name="settings_exact_alarms_allowed">Reminders fire at the exact time</string>
<string name="settings_exact_alarms_blocked">Blocked — tap to allow exact reminders</string>
<string name="settings_section_tasks">Tasks</string>
<string name="settings_default_list">Default list</string>
<string name="settings_default_list_first">First available list</string>
<string name="settings_add_subtask_row">Show \"add a subtask\" row</string>
<string name="settings_add_subtask_row_hint">An add field at the end of an expanded task\'s subtasks</string>
<string name="settings_bottom_add_bar">Bottom quick-add bar</string>
<string name="settings_bottom_add_bar_hint">Add tasks from a bar pinned to the bottom of a list, instead of the floating button</string>
<!-- Reminder lead times (custom amounts) -->
<plurals name="reminder_minutes">
<item quantity="one">%1$d minute before</item>
<item quantity="other">%1$d minutes before</item>
</plurals>
<plurals name="reminder_hours">
<item quantity="one">%1$d hour before</item>
<item quantity="other">%1$d hours before</item>
</plurals>
<plurals name="reminder_days">
<item quantity="one">%1$d day before</item>
<item quantity="other">%1$d days before</item>
</plurals>
<plurals name="reminder_weeks">
<item quantity="one">%1$d week before</item>
<item quantity="other">%1$d weeks before</item>
</plurals>
</resources> </resources>

View File

@@ -1,5 +1,5 @@
<resources xmlns:tools="http://schemas.android.com/tools"> <resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.Floret" parent="android:Theme.Material.Light.NoActionBar"> <style name="Theme.Agendula" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item> <item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item> <item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar">true</item> <item name="android:windowLightStatusBar">true</item>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
The languages Agendula ships translations for. Single source of truth: each
entry needs a matching res/values-<tag>/strings.xml, and is surfaced in both
the in-app language picker (parsed at runtime by AppLanguage) and the system
per-app language settings (Android 13+, via android:localeConfig). To add a
translation, drop in the values-<tag> folder and add one <locale> line here.
-->
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
<locale android:name="en" />
</locale-config>

View File

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.floret.data.tasks package de.jeanlucmakiola.agendula.data.tasks
/** A [ColumnReader] backed by a Map, so mappers test without a real Cursor. */ /** A [ColumnReader] backed by a Map, so mappers test without a real Cursor. */
class MapColumnReader(private val values: Map<String, Any?>) : ColumnReader { class MapColumnReader(private val values: Map<String, Any?>) : ColumnReader {

View File

@@ -1,11 +1,11 @@
package de.jeanlucmakiola.floret.data.tasks package de.jeanlucmakiola.agendula.data.tasks
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Instances import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Lists import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Tasks import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.floret.domain.Priority import de.jeanlucmakiola.agendula.domain.Priority
import de.jeanlucmakiola.floret.domain.TaskStatus import de.jeanlucmakiola.agendula.domain.TaskStatus
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
class TaskMapperTest { class TaskMapperTest {

View File

@@ -1,10 +1,10 @@
package de.jeanlucmakiola.floret.data.tasks package de.jeanlucmakiola.agendula.data.tasks
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Lists import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Tasks import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.floret.domain.Priority import de.jeanlucmakiola.agendula.domain.Priority
import de.jeanlucmakiola.floret.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskForm
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import kotlin.time.Instant import kotlin.time.Instant
@@ -28,6 +28,50 @@ class TaskWriteMapperTest {
assertThat(values[Tasks.TZ]).isEqualTo("Europe/Berlin") assertThat(values[Tasks.TZ]).isEqualTo("Europe/Berlin")
} }
@Test
fun `percent complete is written and clamped to 0-100`() {
val values = TaskWriteMapper.taskValues(
TaskForm(title = "x", listId = 1L, percentComplete = 140),
tzId = "UTC",
)
assertThat(values[Tasks.PERCENT_COMPLETE]).isEqualTo(100)
val unset = TaskWriteMapper.taskValues(TaskForm(title = "x", listId = 1L), tzId = "UTC")
assertThat(unset[Tasks.PERCENT_COMPLETE]).isNull()
}
@Test
fun `progress keeps completion in sync both ways`() {
// 100% completes the task (timestamp left to the provider / existing value).
val done = TaskWriteMapper.taskValues(
TaskForm(title = "x", listId = 1L, percentComplete = 100),
tzId = "UTC",
)
assertThat(done[Tasks.STATUS]).isEqualTo(TasksContract.STATUS_COMPLETED)
assertThat(done.containsKey(Tasks.COMPLETED)).isFalse()
// Below 100% reopens it and clears the completion timestamp.
val reopened = TaskWriteMapper.taskValues(
TaskForm(title = "x", listId = 1L, percentComplete = 75),
tzId = "UTC",
)
assertThat(reopened[Tasks.STATUS]).isEqualTo(TasksContract.STATUS_IN_PROCESS)
assertThat(reopened[Tasks.COMPLETED]).isNull()
// No percent in the form ⇒ status is left to the complete toggle.
val untouched = TaskWriteMapper.taskValues(TaskForm(title = "x", listId = 1L), tzId = "UTC")
assertThat(untouched.containsKey(Tasks.STATUS)).isFalse()
}
@Test
fun `parent id is written so a task can be filed under another`() {
val values = TaskWriteMapper.taskValues(
TaskForm(title = "x", listId = 1L, parentId = 7L),
tzId = "UTC",
)
assertThat(values[Tasks.PARENT_ID]).isEqualTo(7L)
}
@Test @Test
fun `all-day task clears the timezone`() { fun `all-day task clears the timezone`() {
val form = TaskForm( val form = TaskForm(

View File

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.floret.domain package de.jeanlucmakiola.agendula.domain
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test

View File

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.floret.domain package de.jeanlucmakiola.agendula.domain
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test

View File

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.floret.domain package de.jeanlucmakiola.agendula.domain
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@@ -35,4 +35,13 @@ class TaskFormTest {
val errors = TaskForm(title = "x", listId = 1, reminderMinutesBeforeDue = 10).validate() val errors = TaskForm(title = "x", listId = 1, reminderMinutesBeforeDue = 10).validate()
assertThat(errors).contains(TaskFormError.REMINDER_WITHOUT_DUE) assertThat(errors).contains(TaskFormError.REMINDER_WITHOUT_DUE)
} }
@Test
fun `progress and parent auto-reveal only when they carry a value`() {
assertThat(TaskForm(title = "x", listId = 1).populatedFields()).isEmpty()
assertThat(TaskForm(title = "x", listId = 1, percentComplete = 0, parentId = 0).populatedFields())
.isEmpty()
assertThat(TaskForm(title = "x", listId = 1, percentComplete = 30, parentId = 9).populatedFields())
.containsExactly(TaskFormField.Progress, TaskFormField.Parent)
}
} }

View File

@@ -0,0 +1,47 @@
package de.jeanlucmakiola.agendula.domain
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import kotlin.time.Instant
class TaskSectionsTest {
private val dayMs = 86_400_000L
private val todayStart = Instant.fromEpochMilliseconds(1000 * dayMs)
private val todayEnd = Instant.fromEpochMilliseconds(1001 * dayMs)
private fun at(millis: Long) = Instant.fromEpochMilliseconds(millis)
private fun sectionOf(task: Task) = TaskSections.sectionOf(task, todayStart, todayEnd)
@Test
fun `buckets by due relative to today`() {
assertThat(sectionOf(testTask(due = at(1000 * dayMs - 1)))).isEqualTo(TaskSection.OVERDUE)
assertThat(sectionOf(testTask(due = at(1000 * dayMs + 1)))).isEqualTo(TaskSection.TODAY)
assertThat(sectionOf(testTask(due = at(1001 * dayMs + 1)))).isEqualTo(TaskSection.UPCOMING)
assertThat(sectionOf(testTask(due = null))).isEqualTo(TaskSection.NO_DATE)
}
@Test
fun `completed always lands in completed regardless of due`() {
val overdueButDone = testTask(status = TaskStatus.COMPLETED, due = at(1000 * dayMs - 1))
assertThat(sectionOf(overdueButDone)).isEqualTo(TaskSection.COMPLETED)
}
@Test
fun `of returns only non-empty sections in fixed order`() {
val tasks = listOf(
testTask(id = 1, due = at(1001 * dayMs + 1)), // upcoming
testTask(id = 2, due = at(1000 * dayMs - 1)), // overdue
testTask(id = 3, status = TaskStatus.COMPLETED), // completed
)
val sections = TaskSections.of(tasks, todayStart, todayEnd)
assertThat(sections.map { it.section })
.containsExactly(TaskSection.OVERDUE, TaskSection.UPCOMING, TaskSection.COMPLETED)
.inOrder()
}
@Test
fun `empty input yields no sections`() {
assertThat(TaskSections.of(emptyList(), todayStart, todayEnd)).isEmpty()
}
}

View File

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.floret.domain package de.jeanlucmakiola.agendula.domain
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test

View File

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.floret.domain package de.jeanlucmakiola.agendula.domain
import kotlin.time.Instant import kotlin.time.Instant

View File

@@ -1,35 +0,0 @@
package de.jeanlucmakiola.floret.domain
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import java.time.ZoneId
import kotlin.time.Instant
class DayWindowTest {
@Test
fun `today spans exactly 24h in UTC and contains now`() {
val zone = ZoneId.of("UTC")
val now = Instant.fromEpochMilliseconds(1_750_000_000_000L)
val (start, end) = DayWindow.today(now, zone)
assertThat(end.toEpochMilliseconds() - start.toEpochMilliseconds()).isEqualTo(86_400_000L)
assertThat(now >= start).isTrue()
assertThat(now < end).isTrue()
// In UTC, midnight aligns to a multiple of one day since the epoch.
assertThat(start.toEpochMilliseconds() % 86_400_000L).isEqualTo(0L)
}
@Test
fun `start is local midnight in a non-UTC zone`() {
val zone = ZoneId.of("Europe/Berlin")
val now = Instant.fromEpochMilliseconds(1_750_000_000_000L)
val (start, end) = DayWindow.today(now, zone)
val startLocal = java.time.Instant.ofEpochMilli(start.toEpochMilliseconds()).atZone(zone)
assertThat(startLocal.hour).isEqualTo(0)
assertThat(startLocal.minute).isEqualTo(0)
assertThat(now >= start).isTrue()
assertThat(now < end).isTrue()
}
}

View File

@@ -0,0 +1,11 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="512" height="512" fill="white"/>
<path d="M370.207 254.345V168.276C370.207 144.508 350.939 125.241 327.172 125.241H178.207C154.439 125.241 135.172 144.508 135.172 168.276V317.241C135.172 341.008 154.439 360.276 178.207 360.276H269.241" stroke="black" stroke-width="20" stroke-miterlimit="12" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M193 245.569L231.822 287L320 199" stroke="black" stroke-width="20" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M346.672 323.721C356.301 323.721 364.107 312.367 364.107 298.361C364.107 284.354 356.301 273 346.672 273C337.042 273 329.236 284.354 329.236 298.361C329.236 312.367 337.042 323.721 346.672 323.721Z" stroke="black" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M355.716 330.293C358.692 339.451 371.903 343.366 385.224 339.038C398.544 334.71 406.931 323.777 403.955 314.619C400.98 305.461 387.769 301.546 374.448 305.874C361.127 310.202 352.741 321.135 355.716 330.293Z" stroke="black" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M352.262 340.926C344.471 346.586 344.83 360.36 353.063 371.691C361.295 383.022 374.284 387.62 382.075 381.96C389.865 376.3 389.506 362.526 381.274 351.194C373.041 339.863 360.052 335.266 352.262 340.926Z" stroke="black" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M341.082 340.926C333.291 335.266 320.302 339.863 312.069 351.194C303.837 362.526 303.478 376.3 311.268 381.96C319.059 387.62 332.048 383.022 340.28 371.691C348.513 360.36 348.872 346.585 341.082 340.926Z" stroke="black" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M337.627 330.293C340.602 321.135 332.216 310.202 318.895 305.874C305.574 301.546 292.363 305.461 289.388 314.619C286.412 323.777 294.799 334.71 308.119 339.038C321.44 343.366 334.651 339.451 337.627 330.293Z" stroke="black" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M346.672 342.742C351.924 342.742 356.182 338.484 356.182 333.232C356.182 327.979 351.924 323.721 346.672 323.721C341.419 323.721 337.161 327.979 337.161 333.232C337.161 338.484 341.419 342.742 346.672 342.742Z" stroke="black" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -1,6 +1,6 @@
# Floret — architecture # Agendula — architecture
This document describes how Floret is built **as it stands today**. For the This document describes how Agendula is built **as it stands today**. For the
*why* behind the big decisions and the long-term plan, see *why* behind the big decisions and the long-term plan, see
[`PLAN.md`](PLAN.md); for status and what's next, see [`ROADMAP.md`](ROADMAP.md). [`PLAN.md`](PLAN.md); for status and what's next, see [`ROADMAP.md`](ROADMAP.md).
@@ -8,10 +8,10 @@ This document describes how Floret is built **as it stands today**. For the
## 1. The thesis in one sentence ## 1. The thesis in one sentence
Floret is a Material 3 Expressive **front-end** over the OpenTasks Agendula is a Material 3 Expressive **front-end** over the OpenTasks
`TaskContract` provider — it reads, writes, and reminds on top of a tasks store `TaskContract` provider — it reads, writes, and reminds on top of a tasks store
that some other app (DAVx5, SmoothSync, DecSync CC, tasks.org, …) syncs over that some other app (DAVx5, SmoothSync, DecSync CC, tasks.org, …) syncs over
CalDAV. **Floret owns no database and no sync stack.** It is the task-list CalDAV. **Agendula owns no database and no sync stack.** It is the task-list
sibling to [Calendula](https://gitea.jeanlucmakiola.de/makiolaj/calendula), sibling to [Calendula](https://gitea.jeanlucmakiola.de/makiolaj/calendula),
which does the same thing for `CalendarContract`. which does the same thing for `CalendarContract`.
@@ -69,7 +69,7 @@ Both are bound in Hilt in `data/di/DataModule.kt`.
## 3. Module & package layout ## 3. Module & package layout
Single `:app` module (Posture A). Package root `de.jeanlucmakiola.floret`. Single `:app` module (Posture A). Package root `de.jeanlucmakiola.agendula`.
| Package | Contents | | Package | Contents |
|---|---| |---|---|
@@ -80,7 +80,7 @@ Single `:app` module (Posture A). Package root `de.jeanlucmakiola.floret`.
| `data/di/` | `DataModule` (binds + provides), `Qualifiers` (`@IoDispatcher`). | | `data/di/` | `DataModule` (binds + provides), `Qualifiers` (`@IoDispatcher`). |
| `data/demo/` | `DemoSeeder` (debug-only sample data). | | `data/demo/` | `DemoSeeder` (debug-only sample data). |
| `ui/` | `theme/`, `common/` (GroupedList, ListChip), `lists/`, `tasklist/`, `detail/`, `edit/`, `settings/`, `permission/` (each a ViewModel + UiState; `lists` also has its screen), `RootScreen`. | | `ui/` | `theme/`, `common/` (GroupedList, ListChip), `lists/`, `tasklist/`, `detail/`, `edit/`, `settings/`, `permission/` (each a ViewModel + UiState; `lists` also has its screen), `RootScreen`. |
| root | `FloretApp` (Hilt app), `MainActivity`. | | root | `AgendulaApp` (Hilt app), `MainActivity`. |
--- ---
@@ -106,7 +106,7 @@ runtime perms for the active provider.
### 4.2 `TasksContract` ### 4.2 `TasksContract`
A vendored subset of the Apache-2.0 OpenTasks `TaskContract` — column names, A vendored subset of the Apache-2.0 OpenTasks `TaskContract` — column names,
table paths, status/priority constants, the local-account type. Floret does table paths, status/priority constants, the local-account type. Agendula does
**not** take a runtime dependency on OpenTasks; the authority is injected from **not** take a runtime dependency on OpenTasks; the authority is injected from
`ProviderResolver`, never hardcoded in the contract. `ProviderResolver`, never hardcoded in the contract.
@@ -117,7 +117,7 @@ occurrence is a row with the joined list colour, account, etc.), maps each
cursor row through `ColumnReader``TaskMapper` → domain `Task`, and exposes cursor row through `ColumnReader``TaskMapper` → domain `Task`, and exposes
the result as a `Flow`. A `ContentObserver` on the active authority's the result as a `Flow`. A `ContentObserver` on the active authority's
Tasks/TaskLists URIs bridges into the Flow via `callbackFlow`, so **any change Tasks/TaskLists URIs bridges into the Flow via `callbackFlow`, so **any change
re-emits** — Floret's own writes *and* external sync (DAVx5 pulling new tasks) re-emits** — Agendula's own writes *and* external sync (DAVx5 pulling new tasks)
update the UI live, and multiple sync sources coexist in one list. update the UI live, and multiple sync sources coexist in one list.
### 4.4 Writes — repository API ### 4.4 Writes — repository API
@@ -170,7 +170,7 @@ priority / etc.). None of this touches Android, which is why it's all in
## 6. Reminders — the one subsystem that does NOT mirror Calendula ## 6. Reminders — the one subsystem that does NOT mirror Calendula
Calendula relies on the calendar provider broadcasting `EVENT_REMINDER`. **Tasks Calendula relies on the calendar provider broadcasting `EVENT_REMINDER`. **Tasks
providers broadcast nothing**, so Floret schedules its own (`data/reminders/`): providers broadcast nothing**, so Agendula schedules its own (`data/reminders/`):
- **`ReminderScheduler.sync()`** reads upcoming, non-closed, due-dated tasks - **`ReminderScheduler.sync()`** reads upcoming, non-closed, due-dated tasks
within a rolling **30-day window**, computes each trigger as `due lead` within a rolling **30-day window**, computes each trigger as `due lead`
@@ -186,7 +186,7 @@ providers broadcast nothing**, so Floret schedules its own (`data/reminders/`):
sync changed the data). The store lets each run diff like Calendula diffs sync changed the data). The store lets each run diff like Calendula diffs
reminder rows. reminder rows.
This is the single largest piece of genuinely-new code in Floret. This is the single largest piece of genuinely-new code in Agendula.
--- ---
@@ -201,7 +201,7 @@ This is the single largest piece of genuinely-new code in Floret.
domain, and `TasksRepository` do not change** — only the resolver's default and domain, and `TasksRepository` do not change** — only the resolver's default and
some manifest perms. some manifest perms.
Bundling the provider bundles **storage, not sync**Floret stays a pure Bundling the provider bundles **storage, not sync**Agendula stays a pure
front-end over open backends either way. front-end over open backends either way.
--- ---
@@ -228,8 +228,8 @@ checkbox/FAB/swipe motion).
Hilt, `SingletonComponent`. `DataModule` has a `@Binds` module Hilt, `SingletonComponent`. `DataModule` has a `@Binds` module
(`TasksDataSource``AndroidTasksDataSource`, `TasksRepository` (`TasksDataSource``AndroidTasksDataSource`, `TasksRepository`
`TasksRepositoryImpl`) and a `@Provides` module (the `floret_prefs` DataStore, `TasksRepositoryImpl`) and a `@Provides` module (the `agendula_prefs` DataStore,
the `@IoDispatcher`). `FloretApp` is the `@HiltAndroidApp` entry point; the `@IoDispatcher`). `AgendulaApp` is the `@HiltAndroidApp` entry point;
`MainActivity` is `@AndroidEntryPoint`. ViewModels get the repository injected. `MainActivity` is `@AndroidEntryPoint`. ViewModels get the repository injected.
--- ---
@@ -243,7 +243,7 @@ the `@IoDispatcher`). `FloretApp` is the `@HiltAndroidApp` entry point;
| UI | Compose BOM 2026.05.01, Material3 `1.5.0-alpha21` (Expressive APIs), Glance 1.1.1 (widget, later) | | UI | Compose BOM 2026.05.01, Material3 `1.5.0-alpha21` (Expressive APIs), Glance 1.1.1 (widget, later) |
| Other | DataStore, kotlinx-datetime, kotlinx-coroutines | | Other | DataStore, kotlinx-datetime, kotlinx-coroutines |
| Tests | JUnit5 (Jupiter) + Truth + Turbine + coroutines-test; the data source is the JVM-testable seam | | Tests | JUnit5 (Jupiter) + Truth + Turbine + coroutines-test; the data source is the JVM-testable seam |
| Versioning | git tag is the source of truth; `versionCode = MAJOR*10000 + MINOR*100 + PATCH`, derived in CI at release. See [`RELEASING.md`](RELEASING.md). | | Versioning | committed `versionName` is the source of truth; a bump reaching `main` triggers the release and the pipeline mints the `vX.Y.Z` tag. `versionCode = MAJOR*10000 + MINOR*100 + PATCH`. See [`RELEASING.md`](RELEASING.md). |
| CI | Gitea workflows (`.gitea/workflows/ci.yaml`, `release.yaml`) | | CI | Gitea workflows (`.gitea/workflows/ci.yaml`, `release.yaml`) |
| Distribution | F-Droid (`fdroid-metadata/`) | | Distribution | F-Droid (`fdroid-metadata/`) |

View File

@@ -1,23 +1,25 @@
# Floret — implementation plan # Agendula — implementation plan
> A modern Material 3 Expressive **task** app for Android. Reads, writes, and > A modern Material 3 Expressive **task** app for Android. Reads, writes, and
> reminds — on top of an existing tasks provider (synced by DAVx5 / SmoothSync / > reminds — on top of an existing tasks provider (synced by DAVx5 / SmoothSync /
> DecSync over CalDAV), with no own sync stack. > DecSync over CalDAV), with no own sync stack.
> >
> Sibling to **Calendula**. Calendula is a calendar over `CalendarContract`; > Sibling to **Calendula**. Calendula is a calendar over `CalendarContract`;
> Floret is a to-do list over the **OpenTasks `TaskContract` provider**. A > Agendula is a to-do list over the **OpenTasks `TaskContract` provider**. The
> Calendula flower head is made of many small *florets* — the individual items > name mirrors Calendula's: *agenda* (Latin, “things to be done”) + the `-ula`
> that make up the bloom. > ending — and a Calendula flower head is itself a cluster of small *florets*,
> so the two apps are florets of one bloom.
Working identifiers (placeholder, easily renamed): Identifiers: `applicationId = de.jeanlucmakiola.agendula`, app name **Agendula**
`applicationId = de.jeanlucmakiola.floret`, app name **Floret**. (renamed from the working title *Floret*, which was promoted to the shared
family / design-language name).
--- ---
## 0. The thesis this app embodies ## 0. The thesis this app embodies
A nice M3-Expressive front end over open backends, no reinvented storage or A nice M3-Expressive front end over open backends, no reinvented storage or
sync. Calendula proved the pattern against the OS calendar provider. Floret sync. Calendula proved the pattern against the OS calendar provider. Agendula
applies it to tasks. The crucial difference: **there is no OS tasks provider**, applies it to tasks. The crucial difference: **there is no OS tasks provider**,
so we depend on a tasks *provider app* being present — exactly as Calendula so we depend on a tasks *provider app* being present — exactly as Calendula
depends on a sync app like DAVx5 for CalDAV. depends on a sync app like DAVx5 for CalDAV.
@@ -46,7 +48,7 @@ string leak above the data layer.**
Calendula's layering is the template. Lift these **verbatim or near-verbatim**: Calendula's layering is the template. Lift these **verbatim or near-verbatim**:
| Area | From Calendula | Change for Floret | | Area | From Calendula | Change for Agendula |
|---|---|---| |---|---|---|
| Gradle setup | `build.gradle.kts`, `settings.gradle.kts`, `gradle/libs.versions.toml`, wrapper, `key.properties` flow, versionCode-from-tag CI | namespace/appId only | | Gradle setup | `build.gradle.kts`, `settings.gradle.kts`, `gradle/libs.versions.toml`, wrapper, `key.properties` flow, versionCode-from-tag CI | namespace/appId only |
| Build config | AGP 9.2.1, Kotlin 2.3.21, KSP, Hilt 2.59.2, compileSdk 37 / minSdk 29 / targetSdk 36, Java 17 | identical | | Build config | AGP 9.2.1, Kotlin 2.3.21, KSP, Hilt 2.59.2, compileSdk 37 / minSdk 29 / targetSdk 36, Java 17 | identical |
@@ -65,7 +67,7 @@ Calendula's layering is the template. Lift these **verbatim or near-verbatim**:
`TaskContract` data layer, the task screens, and a **self-scheduled reminder `TaskContract` data layer, the task screens, and a **self-scheduled reminder
engine** (see §6 — the one place Calendula's pattern does *not* carry over). engine** (see §6 — the one place Calendula's pattern does *not* carry over).
### What does NOT exist here (why Floret is simpler than Calendula) ### What does NOT exist here (why Agendula is simpler than Calendula)
No month/week/day grid rendering. No recurrence-scoped writes ("this & following" No month/week/day grid rendering. No recurrence-scoped writes ("this & following"
vs "whole series"). No timezone/all-day gymnastics. Tasks are a flat-or-lightly- vs "whole series"). No timezone/all-day gymnastics. Tasks are a flat-or-lightly-
@@ -76,7 +78,7 @@ nested list with a due date and a checkbox.
## 2. Module & package layout ## 2. Module & package layout
Single `:app` module for A (mirrors Calendula). Package root Single `:app` module for A (mirrors Calendula). Package root
`de.jeanlucmakiola.floret`. `de.jeanlucmakiola.agendula`.
``` ```
domain/ domain/
@@ -111,10 +113,10 @@ ui/
edit/ TaskEditScreen + VM + UiState edit/ TaskEditScreen + VM + UiState
settings/ (copy + adapt) settings/ (copy + adapt)
permission/ provider-presence + permission + POST_NOTIFICATIONS onboarding permission/ provider-presence + permission + POST_NOTIFICATIONS onboarding
FloretHost.kt nav host (mirrors CalendarHost) AgendulaHost.kt nav host (mirrors CalendarHost)
RootScreen.kt RootScreen.kt
widget/ Glance task widget (later milestone) widget/ Glance task widget (later milestone)
FloretApp.kt, MainActivity.kt AgendulaApp.kt, MainActivity.kt
``` ```
--- ---
@@ -217,9 +219,9 @@ Material 3 Expressive throughout: `MaterialExpressiveTheme`,
Three gates, in order: Three gates, in order:
1. **Provider present?** `ProviderResolver.detect()`. If none → a screen 1. **Provider present?** `ProviderResolver.detect()`. If none → a screen
explaining Floret needs a tasks provider, with one-tap links to install explaining Agendula needs a tasks provider, with one-tap links to install
**OpenTasks** (FOSS) or **tasks.org**, plus "I use DAVx5 — set its Tasks app". **OpenTasks** (FOSS) or **tasks.org**, plus "I use DAVx5 — set its Tasks app".
(This is Floret's "needs DAVx5" moment. Disappears entirely under Posture B.) (This is Agendula's "needs DAVx5" moment. Disappears entirely under Posture B.)
2. **Tasks read/write permission** — request the active provider's runtime perms 2. **Tasks read/write permission** — request the active provider's runtime perms
(`org.dmfs.permission.*` etc.). Declared in the manifest *and* requested at (`org.dmfs.permission.*` etc.). Declared in the manifest *and* requested at
runtime; resolved dynamically from the detected provider. runtime; resolved dynamically from the detected provider.
@@ -235,7 +237,7 @@ exactly like Calendula's launcher query.
Calendula relies on the **calendar provider broadcasting `EVENT_REMINDER`** and Calendula relies on the **calendar provider broadcasting `EVENT_REMINDER`** and
just posts the notification (Etar model). **Tasks providers do not broadcast just posts the notification (Etar model). **Tasks providers do not broadcast
reminders.** So Floret must schedule its own: reminders.** So Agendula must schedule its own:
- `DueReminderScheduler` (AlarmManager, exact alarms via `USE_EXACT_ALARM` / - `DueReminderScheduler` (AlarmManager, exact alarms via `USE_EXACT_ALARM` /
`SCHEDULE_EXACT_ALARM`) sets an alarm per task at `DUE` minus the chosen `SCHEDULE_EXACT_ALARM`) sets an alarm per task at `DUE` minus the chosen
@@ -273,7 +275,7 @@ receiver. **No `EVENT_REMINDER` receiver** (doesn't apply).
## 8. Milestones ## 8. Milestones
- **M0 — Skeleton.** Copy Gradle/version-catalog/theme/DI/app+activity from - **M0 — Skeleton.** Copy Gradle/version-catalog/theme/DI/app+activity from
Calendula, rename to Floret. Builds, shows themed empty scaffold. *(~½ day)* Calendula, rename to Agendula. Builds, shows themed empty scaffold. *(~½ day)*
- **M1 — Read path.** `TasksContract` + `ProviderResolver` + `OpenTasksDataSource` - **M1 — Read path.** `TasksContract` + `ProviderResolver` + `OpenTasksDataSource`
+ repository. Lists overview + task list render real synced tasks (read-only), + repository. Lists overview + task list render real synced tasks (read-only),
live-updating via ContentObserver. *(the meaty milestone)* live-updating via ContentObserver. *(the meaty milestone)*
@@ -297,21 +299,22 @@ M4 (subtasks) and M5 (reminders) are the two spots needing real thought.
## 9. Open decisions / to verify ## 9. Open decisions / to verify
1. **Name**`Floret` is the working title (florets compose a Calendula head). 1. ~~**Name**~~**Agendula** (final): *agenda* + Calendula's `-ula`. Renamed
Confirm or replace before M0 (touches appId, namespace, package). from the working title *Floret*, which was promoted to the family /
design-language name.
2. **tasks.org provider authority** — verify on a real device; OpenTasks 2. **tasks.org provider authority** — verify on a real device; OpenTasks
(`org.dmfs.tasks`) is the certain target for v1. (`org.dmfs.tasks`) is the certain target for v1.
3. **jtx Board** — support its richer contract later, or stay OpenTasks-only? 3. **jtx Board** — support its richer contract later, or stay OpenTasks-only?
4. **B authority choice** — bundling `org.dmfs.tasks` makes Floret a *replacement* 4. **B authority choice** — bundling `org.dmfs.tasks` makes Agendula a *replacement*
for OpenTasks (one authority owner per device). Intended (one app instead of for OpenTasks (one authority owner per device). Intended (one app instead of
two), but a conscious choice. two), but a conscious choice.
5. **Repo home** — sibling Gitea repo next to Calendula, MIT license, same CI. 5. **Repo home** — sibling Gitea repo next to Calendula, MIT license, same CI.
--- ---
## 10. Sync sources Floret inherits for free (README copy) ## 10. Sync sources Agendula inherits for free (README copy)
Because Floret builds on the provider, not on any one sync app, it works with Because Agendula builds on the provider, not on any one sync app, it works with
**anything that writes to the tasks provider**: DAVx5 (CalDAV), SmoothSync, **anything that writes to the tasks provider**: DAVx5 (CalDAV), SmoothSync,
CalDAV-Sync, DecSync CC, and any Android sync adapter — no per-app integration. CalDAV-Sync, DecSync CC, and any Android sync adapter — no per-app integration.
Google Tasks / Microsoft To Do are out of scope by design (proprietary, would Google Tasks / Microsoft To Do are out of scope by design (proprietary, would

View File

@@ -1,6 +1,6 @@
# Floret — documentation # Agendula — documentation
Floret is a Material 3 Expressive **task** app for Android: a pure front-end over Agendula is a Material 3 Expressive **task** app for Android: a pure front-end over
the OpenTasks `TaskContract` provider (synced by DAVx5 / SmoothSync / DecSync the OpenTasks `TaskContract` provider (synced by DAVx5 / SmoothSync / DecSync
over CalDAV), with no own database or sync stack. Sibling to over CalDAV), with no own database or sync stack. Sibling to
[Calendula](https://gitea.jeanlucmakiola.de/makiolaj/calendula). See the [Calendula](https://gitea.jeanlucmakiola.de/makiolaj/calendula). See the
@@ -10,7 +10,7 @@ top-level [`../README.md`](../README.md) for the project pitch.
| Doc | What it covers | | Doc | What it covers |
|---|---| |---|---|
| [`ARCHITECTURE.md`](ARCHITECTURE.md) | How Floret is built **today** — layers, the data seam, provider resolution, the reminder engine, DI, build/tooling, manifest. Start here to work on the code. | | [`ARCHITECTURE.md`](ARCHITECTURE.md) | How Agendula is built **today** — layers, the data seam, provider resolution, the reminder engine, DI, build/tooling, manifest. Start here to work on the code. |
| [`ROADMAP.md`](ROADMAP.md) | **Status** and what's next — milestones (M0M6 + Posture B), what's done, open decisions, how to build/verify. | | [`ROADMAP.md`](ROADMAP.md) | **Status** and what's next — milestones (M0M6 + Posture B), what's done, open decisions, how to build/verify. |
| [`PLAN.md`](PLAN.md) | The original implementation plan and **design rationale** — the A-now-B-later thesis, what transfers from Calendula, the locked decisions. The "why". | | [`PLAN.md`](PLAN.md) | The original implementation plan and **design rationale** — the A-now-B-later thesis, what transfers from Calendula, the locked decisions. The "why". |
| [`RELEASING.md`](RELEASING.md) | How to cut a release — the git-tag-as-source-of-truth flow, CI jobs, F-Droid repo, required secrets. | | [`RELEASING.md`](RELEASING.md) | How to cut a release — the git-tag-as-source-of-truth flow, CI jobs, F-Droid repo, required secrets. |

View File

@@ -1,101 +1,163 @@
# Floret — releasing # Agendula — releasing
Floret is distributed through a **self-hosted F-Droid repo** (on Hetzner) with a Agendula is distributed through a **self-hosted F-Droid repo** (on Hetzner) with
human-readable **Gitea release** per tag. Both are produced automatically by a human-readable **Gitea release** per version. Both are produced automatically
`.gitea/workflows/release.yaml` when you push a tag. There are no APK assets on by `.gitea/workflows/release.yaml` when a **bumped `versionName` reaches `main`**
the Gitea release itself — distribution lives in the F-Droid repo; the release is the pipeline builds and publishes that version, then creates the matching
the changelog of record. `vX.Y.Z` tag and Gitea release itself. There are no APK assets on the Gitea
release: distribution lives in the F-Droid repo; the release is the changelog of
record.
While Agendula is pre-1.0 (`versionName` starts with `0.`), every Gitea release
is flagged as a **pre-release**. This happens automatically and graduates to a
stable release at `1.0.0` — no manual toggling.
--- ---
## The one source of truth: the git tag ## The source of truth: the committed version
The git tag drives the version. You do **not** hand-edit version numbers for a A release is defined by the `versionName`/`versionCode` committed in
release — CI substitutes them from the tag: `app/build.gradle.kts`**not** by a hand-pushed tag:
- `versionName` = the tag without a leading `v` (e.g. `v0.2.0` `0.2.0`). - `versionName` = `MAJOR.MINOR.PATCH` (e.g. `0.2.0`)
- `versionCode` = `MAJOR*10000 + MINOR*100 + PATCH` (e.g. `0.2.0``200`, - `versionCode` = `MAJOR*10000 + MINOR*100 + PATCH` (`0.2.0``200`,
`1.3.4``10304`). `1.3.4``10304`)
The values committed in `app/build.gradle.kts` are just the local/dev default; So `MINOR` and `PATCH` each have room for 099. The release pipeline reads
keep them roughly matching the latest released tag, but the tag wins at release `versionName`, pins `versionCode` to the derived value, builds, publishes, and —
time. once the APK is live — creates the tag `v<versionName>` at that commit. The tag
is an **output** of a successful release, not its trigger, so a tag always marks
a fully-shipped version (and a failure before publish leaves no tag, so
re-running the workflow safely retries).
--- ---
## Cutting a release ## Cutting a release
1. **Update `CHANGELOG.md`.** Move items out of `[Unreleased]` into a new 1. **Assemble the release branch.** Create `release/vX.Y.Z` and merge the
`## [X.Y.Z]` section. The release pipeline extracts everything between feature/fix branches for this release into it. Everything below happens on
`## [X.Y.Z]` and the next `## [` heading and uses it verbatim as both the that branch, before it reaches `main`.
Gitea release notes and the F-Droid per-version "What's New" 2. **Update `CHANGELOG.md`.** Move the `## [Unreleased]` items under a new
(`changelogs/<versionCode>.txt`). If no matching section exists, a fallback `## [X.Y.Z]` heading (Keep a Changelog format). The text between that heading
line is used — so the heading **must** match the tag's version exactly. and the next `## [` becomes both the Gitea release notes and the F-Droid
2. **Commit** the changelog on `main`. per-version "What's New". The heading **must** match the version exactly.
3. **Tag and push:** 3. **Bump the committed `versionName`** (and `versionCode`) in
`app/build.gradle.kts`. **This bump is what triggers the release** when the
branch merges to `main`. Then run
```sh ```sh
git tag v0.2.0 scripts/sync_changelog_to_fastlane.sh
git push origin v0.2.0
``` ```
4. CI takes over (see below). Watch the run in Gitea Actions. and commit the generated
`fastlane/metadata/android/en-US/changelogs/<versionCode>.txt` — this is what
makes the **official** F-Droid listing (which harvests the changelog from the
tagged source tree) show this version. The self-hosted pipeline regenerates it
regardless, so forgetting only affects the official listing.
4. **Verify the release build on a real device** — the mandatory gate:
```sh
scripts/verify-release.sh
```
It builds the `releaseTest` variant (same R8 config as `release`, debug-signed
with a `.releasetest` suffix so it installs alongside the real app) and resets
it to a first-run state. Then, on the device:
- launch from a **clean / permission-not-granted** state — the permission
screen must appear, no crash;
- grant tasks access — the task list must load;
- create a task with a due reminder and confirm the notification fires;
- exercise this release's headline changes.
Only proceed once all of that passes on-device.
5. **Merge `release/vX.Y.Z` into `main`.** That's it — no manual tagging. The
merge triggers `release.yaml`, which detects the new version, builds, signs,
publishes to F-Droid, and creates the `vX.Y.Z` tag + Gitea release.
> The `releaseTest` build type exists only for step 4 — it is never published.
> The pipeline always builds and signs the real `release` variant.
--- ---
## What CI does on a tag ## What the pipeline does
`release.yaml` runs three jobs: CI and release are split so a change is built once on its PR and only does
release work when a merge actually cuts a release:
| Job | Purpose | - **`ci.yaml`** (on `pull_request`) — the reproducible-release invariant guard
|---|---| (`scripts/check_reproducible_release.sh`), then lint + unit tests + a debug
| `ci` | Sanity gate: unit tests + a debug build (catches version-substitution drift). The other jobs depend on this. | assemble and a Trivy scan, once per PR. Docs/metadata-only PRs skip the Android
| `build-and-deploy` | Substitute version from the tag → build signed release APK → fetch the existing F-Droid repo from Hetzner → add the new APK + per-version changelog → `fdroid update -c` → upload `repo/` + `metadata/` back. Also attaches the R8 `mapping.txt.gz` to the Gitea release (best-effort) so crash stacktraces stay deobfuscatable. | build but still report a green `CI` check.
| `gitea-release` | Create/update the Gitea release for the tag, body = the extracted CHANGELOG section. Gated on `ci` only (not deploy), so notes still publish if the F-Droid upload hiccups. | - **`release.yaml`** (on push to `main`, plus `workflow_dispatch`) — a cheap
`detect` job reads `versionName` and checks whether a tag for it already
exists. Only when it doesn't does the `release` job run: unit tests on the
merged commit, pin `versionCode`, build & sign the release APK with the **app
key**, copy it into the F-Droid repo, generate the per-version changelog from
the fastlane tree, re-sign the index with the **repo key**, upload `repo/` +
`metadata/`, then create the `vX.Y.Z` tag + Gitea release (CHANGELOG section as
notes, flagged pre-release while `MAJOR` is 0), attach the R8 `mapping.txt`,
and mirror the release to **Codeberg** with the signed APK + a SHA-256 checksum
(both best-effort). Ordinary merges with no version bump fall through `detect`
and do nothing.
Both deploy and release steps are **re-run safe** (idempotent upserts), so a ### Codeberg direct-download channel
failed run can be retried.
Alongside F-Droid, each release is mirrored to the Codeberg repo
(`jlmakiola/agendula`) as a plain download for users who don't want F-Droid.
Gitea already **push-mirrors** branches and tags to Codeberg, but releases
aren't git objects and don't sync, so the pipeline creates the release over the
Codeberg API and attaches `agendula_v<version>.apk` + its `.sha256`. It's the
same APK the F-Droid repo serves (same **app key**), so it adds no trust surface.
The step is best-effort: a Codeberg outage never fails an already-published
F-Droid release, and it skips cleanly if `CODEBERG_RELEASE_TOKEN` is unset.
One-time setup: the Codeberg repo's **Releases** unit must be enabled and a
`CODEBERG_RELEASE_TOKEN` secret (Codeberg access token, `write:repository` scope)
added to Gitea Actions.
### Manual re-sign / recovery
A manual `workflow_dispatch` of the release workflow runs a **re-sign-only**
path: `detect` reports it's not a release, so the `release` job skips the APK
build, the version pin, and tag/release creation, and just re-signs the existing
F-Droid index with the configured repo key and re-uploads. Use this for key
rotation or repo recovery without publishing a new app version.
--- ---
## Required CI secrets ## Secrets (Gitea → repo Settings → Actions → Secrets)
Configured in the Gitea repo settings; the workflow fails loudly if the F-Droid The workflow fails loudly if the F-Droid ones are missing — it will **never**
ones are missing (it will **never** auto-generate a repo key that would rotate auto-generate a repo key (that would rotate the repo fingerprint and break every
the repo fingerprint and break every user's pinned repo). user's pinned repo).
| Secret | Used for | | Secret | Purpose |
|---|---| | --- | --- |
| `KEYSTORE_BASE64`, `KEY_PASSWORD`, `KEY_ALIAS` | App signing keystore (the APK). | | `KEYSTORE_BASE64`, `KEY_PASSWORD`, `KEY_ALIAS` | **App** signing key — signs the APK. Losing it means existing installs can't be updated. |
| `FDROID_KEYSTORE_BASE64`, `FDROID_CONFIG_BASE64` | The F-Droid **repo** signing key + `config.yml`. Never uploaded to the server. | | `FDROID_KEYSTORE_BASE64` | **F-Droid repo** signing key (`keystore.p12`, base64). Signs the repo index. |
| `HETZNER_HOST`, `HETZNER_USER`, `HETZNER_PASS` | SFTP target for the published `repo/` + `metadata/`. | | `FDROID_CONFIG_BASE64` | F-Droid `config.yml` (base64) — repo metadata + keystore passwords. |
| `GITHUB_TOKEN` | Gitea API (release create/patch, asset upload). | | `HETZNER_HOST`, `HETZNER_USER`, `HETZNER_PASS` | Upload target for the F-Droid repo. |
| `GITHUB_TOKEN` | Provided by Gitea Actions; used to create the release + attach assets. |
| `CODEBERG_RELEASE_TOKEN` | Codeberg access token (`write:repository` scope) — creates the mirrored Codeberg release + uploads the APK/checksum. Best-effort; if unset the Codeberg step skips. |
The repo signing key and `config.yml` come from secrets at build time and are The app key signs APKs; the repo key signs the index (its fingerprint is what
**never** pulled from or pushed back to the server, so they can't leak into the users pin). Neither key nor `config.yml` is ever uploaded to the server they
web-served tree (nginx serves only `repo/`). live only in CI secrets and are reconstructed in-runner (nginx serves only
`repo/`).
--- ---
## Key rotation / repo recovery ## F-Droid metadata (single source of truth)
A manual `workflow_dispatch` run (ref = a branch, not a tag) skips all the Store-listing text lives in **`fastlane/metadata/android/<locale>/`** — the same
tag-only build steps: it just re-signs the existing index with the configured tree the official F-Droid repo harvests from source. At release time
repo key and re-uploads. Use this to recover the repo or rotate infrastructure `scripts/fastlane_to_fdroid_localized.sh` transforms it into the F-Droid repo's
**without** publishing a new APK. "localized" layout, so there is no second copy to maintain. The app-level control
file (`Categories`/`License`/links) stays in
`fdroid-metadata/de.jeanlucmakiola.agendula.yml`. Per-version changelogs are
seeded into `fastlane/.../en-US/changelogs/<versionCode>.txt` by
`scripts/sync_changelog_to_fastlane.sh` (step 3 above) and carried across by the
transform.
--- ---
## Push CI (non-tag) ## Crash deobfuscation
Every push to any branch runs `.gitea/workflows/ci.yaml`: `lintDebug` → Each release attaches `mapping-<version>.txt.gz` (the R8 mapping) to its Gitea
`testDebugUnitTest` → `assembleDebug`, plus a Trivy filesystem scan on `main`. release. To deobfuscate a user stacktrace, download the mapping for that version
Keep this green before tagging. and run it through `retrace`.
---
## F-Droid metadata
App store listing lives in `fdroid-metadata/` (`de.jeanlucmakiola.floret.yml`
plus `en-US/` summary/description). Per-version changelogs are generated into the
repo's `metadata/.../en-US/changelogs/<versionCode>.txt` from `CHANGELOG.md` at
release time; metadata is uploaded alongside `repo/` so changelog history
survives across releases.

View File

@@ -1,4 +1,4 @@
# Floret — roadmap # Agendula — roadmap
Where the project is and where it's going. This is the **status** view; the Where the project is and where it's going. This is the **status** view; the
design rationale behind each milestone lives in [`PLAN.md`](PLAN.md), and how the design rationale behind each milestone lives in [`PLAN.md`](PLAN.md), and how the
@@ -11,9 +11,15 @@ Status legend: ✅ done · 🚧 in progress · ⬜ not started
## Current state (one line) ## Current state (one line)
The full non-visual stack ("backoffice") over the OpenTasks `TaskContract` The full non-visual stack ("backoffice") over the OpenTasks `TaskContract`
provider is **done and unit-tested**; the Material 3 Expressive UI is being provider is **done and unit-tested**, and the Material 3 Expressive UI is built
built screen by screen on top of it. App builds, gates on provider/permission, through **M5**: lists → task list (swipe gestures, inline add, smart-list section
and renders the lists overview. headers) → detail / edit with full CRUD, date-time pickers, priority,
percent-complete, conflict-safe saves, per-task reminders, and subtask
create + reparent — plus a one-time reminder onboarding step and a Settings
screen (theme, dynamic colour, due-reminder master toggle + default offset +
exact-alarm status, default list, and the add-a-subtask-row opt-out). Remaining
work is M6 (Glance widget, translations, F-Droid release; the Settings screen
landed early with M5 and still needs a language entry).
--- ---
@@ -39,36 +45,88 @@ The complete non-visual stack:
- JVM unit tests across mappers, filtering, sorting, form, value mapping, and - JVM unit tests across mappers, filtering, sorting, form, value mapping, and
day windows. day windows.
### 🚧 M2 — Screens: lists, task list, complete & CRUD ### M2 — Screens: lists, task list, complete & CRUD
Replace the functional scaffold with the real Material 3 Expressive UI, screen The real Material 3 Expressive UI, built screen by screen against the M1
by screen, against the already-built ViewModels. ViewModels.
- ✅ Provider/permission onboarding gate (`RootScreen`). - ✅ Provider/permission onboarding gate (`RootScreen`).
- ✅ Lists overview (`ListsScreen`) — smart lists + user lists grouped by account. - ✅ Lists overview (`ListsScreen`) — smart lists + user lists grouped by account.
- 🚧 Navigation host wiring (the `onOpenFilter` / `onNewTask` callbacks in - Navigation host wiring (`AgendulaNavHost` + `Dest` route table: lists → task
`RootScreen` are currently stubs). list → detail / edit, each binding its M1 ViewModel from route args).
- Task list screen — checkbox rows, swipe-to-complete / swipe-to-delete, - Task list screen — checkbox rows + toggle-complete, swipe-to-complete /
inline add field, section headers for smart lists, FAB. -delete (`SwipeToDismissBox`), inline add field (`InlineAdd``quickAdd`),
- ⬜ Toggle complete (swipe + checkbox), create / edit / delete, local-list smart-list section headers (`TaskSections`).
creation wired to the UI. - ✅ Detail + edit screens — detail shows title / description / subtasks with
edit+delete; edit has the full CRUD form (title, description, save) wired to a
list picker.
### M3 — Detail / edit polish ### M3 — Detail / edit polish
Task detail and edit screens: due/start date-time pickers, priority, - ✅ Due / start date-time pickers (`DateTimePickerFlow` in `TaskEditScreen`).
percent-complete, conflict-safe saves (re-check before overwrite), smart-list - ✅ Priority — coloured by level: green / amber / red pastels (`priorityFill`;
section presentation. M3 has no priority role, only `error`). A shared `ui/common/PriorityChip` on the
list and detail screens, and the edit form's M3 segmented selector tints its
active segment in the chosen level's hue.
- ✅ Smart-list section presentation (`TaskSections` headers on the task list).
- ✅ Percent-complete field — optional "Progress" slider (5% detents) on the edit
form; writes `Tasks.PERCENT_COMPLETE` (clamped 0100, status left to the
complete toggle).
- ✅ Conflict-safe saves — `updateTask` re-checks `last_modified` against the
value captured when the form loaded and throws `TaskConflictException`; the
editor offers overwrite-or-cancel instead of clobbering an external change.
### M4 — Subtasks (UI) ### M4 — Subtasks (UI)
`RELATED-TO` hierarchy in the UI: indentation, create subtask, reparent. The `RELATED-TO` hierarchy in the UI. The data layer already reads `parentId`.
data layer already reads `parentId`; this is the trickiest UI piece. - ✅ Create subtask (`AddSubtaskField``TaskDetailViewModel.addSubtask` sets
`parentId`); subtasks render as a grouped section in the detail screen. Tapping
a subtask opens its own detail (a new `TaskDetail` entry, so it can have
children too), and the subtask's detail shows a tappable "Part of …" parent
card so it never reads as a stray standalone task.
- ✅ Inline expansion on the task list — a parent row has a dedicated expand
button (trailing chevron, separate from the count chip). Each section flattens
into one grouped run (parents + their expanded children) and corners are chosen
per-edge (`cornerPosition`): top-level tasks keep their own run, an expanded
parent opens its bottom to its children, and the child group rounds off on its
last segment while the next top-level task stays mid-run. Children are
full-width, set a step down in tone (`surfaceContainer` vs the parents'
`surfaceContainerHigh`) and with no colour bar (checkbox stays aligned). An expanded group ends with an inline "add a subtask"
row (on by default; opt out in Settings → Tasks since M5).
Offered only where the list holds all the children (a real list; smart lists
that omit off-day children stay collapsed). `TaskDetail.parent` carries the
parent for the detail card.
- ✅ Reparent — a full-width, searchable "Parent task" sheet on the edit form
groups active candidates by due-date section (Overdue / Today / Upcoming / No
date) and files a task under any top-level task in its list (or "None" to
promote it); switching list clears the now-invalid parent. Candidates stay
active + top-level to keep nesting one level deep, matching the detail screen.
### M5 — Reminders onboarding & polish ### M5 — Reminders onboarding & polish
The engine exists (M1). Remaining: the `POST_NOTIFICATIONS` + exact-alarm The engine exists (M1: `ReminderScheduler` + boot / provider-change re-sync,
onboarding flow, per-task / default reminder-offset settings UI, and `DueReminderReceiver`, `TaskNotifier`).
end-to-end verification on device. - ✅ Per-task reminder-offset UI (`ReminderPickerDialog``TaskForm`
`reminderMinutesBeforeDue`).
-`POST_NOTIFICATIONS` onboarding flow — a one-time `ReminderOnboardingScreen`
(Calendula's shell + copy adapted for tasks) gated in `RootScreen` after the
provider/permission grant; it requests the runtime permission (API 33+) and
records the choice (`reminderOnboardingDone`). Re-requestable from Settings.
- ✅ Exact-alarm surface — a status row in Settings → Reminders, shown only on
Android 12 (where `SCHEDULE_EXACT_ALARM` is revocable), deep-linking to the
system grant screen; on 13+ the app holds `USE_EXACT_ALARM` (always granted).
- ✅ Default reminder-offset settings UI — exposed as "When to remind" in
Settings → Reminders (`reminderLeadMinutes`), behind a master `remindersEnabled`
switch that gates the entire engine (`ReminderScheduler` clears all alarms when
off; `DueReminderReceiver` suppresses any in-flight fire).
- ⬜ End-to-end verification on device (build + unit tests green; not yet run on
a device with a live provider).
### M6 — Settings, widget, i18n, release ### 🚧 M6 — Settings, widget, i18n, release
Settings screen (theme / dynamic-color / language, default list, default - ✅ F-Droid metadata scaffolded (`fdroid-metadata/`).
reminder), Glance task-list widget, translations, finalize F-Droid metadata, - ✅ Settings screen — landed early with M5 (`SettingsScreen` in the nav graph,
confirm CI release flow. reached by the gear on the lists overview). Covers theme, dynamic colour, due
reminders (toggle + default offset + exact-alarm status), default list, and the
add-a-subtask-row opt-out. Still ⬜ a **language** entry (deferred until there
are translations to switch to).
- ⬜ Glance task-list widget — deps present in `build.gradle.kts`, zero impl.
- ⬜ Translations — only `res/values/` (English); no `values-XX`.
- ⬜ Finalize F-Droid metadata, confirm CI release flow.
### ⬜ Posture B (separate track, later) ### ⬜ Posture B (separate track, later)
Add a `:provider` module bundling the Apache-2.0 `opentasks-provider`; Add a `:provider` module bundling the Apache-2.0 `opentasks-provider`;
@@ -82,12 +140,12 @@ permissions; ship self-contained. UI / repository / domain untouched — see
These carry over from [`PLAN.md`](PLAN.md) §9; resolved ones are struck through. These carry over from [`PLAN.md`](PLAN.md) §9; resolved ones are struck through.
1. ~~**Name** — `Floret`~~ confirmed (appId `de.jeanlucmakiola.floret`). 1. ~~**Name** — `Agendula`~~ confirmed (appId `de.jeanlucmakiola.agendula`).
2. ~~**tasks.org provider authority**~~ verified on device: 2. ~~**tasks.org provider authority**~~ verified on device:
`org.tasks.opentasks` + `org.tasks.permission.*`. `org.tasks.opentasks` + `org.tasks.permission.*`.
3. **jtx Board** — support its richer contract later, or stay OpenTasks-only? 3. **jtx Board** — support its richer contract later, or stay OpenTasks-only?
(Not in the candidate list today.) (Not in the candidate list today.)
4. **Posture B authority choice** — bundling `org.dmfs.tasks` makes Floret a 4. **Posture B authority choice** — bundling `org.dmfs.tasks` makes Agendula a
*replacement* for OpenTasks (one authority owner per device). Intended, but a *replacement* for OpenTasks (one authority owner per device). Intended, but a
conscious choice. conscious choice.
5. **Recurring tasks** — read as occurrences today (`isRecurring` flag exists); 5. **Recurring tasks** — read as occurrences today (`isRecurring` flag exists);

Some files were not shown because too many files have changed in this diff Show More