28 Commits
v0.1.0 ... main

Author SHA1 Message Date
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
98 changed files with 6029 additions and 910 deletions

View File

@@ -21,6 +21,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup Java
uses: actions/setup-java@v4

View File

@@ -15,6 +15,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup Java
uses: actions/setup-java@v4
@@ -73,6 +75,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup Java
uses: actions/setup-java@v4
@@ -225,7 +229,7 @@ jobs:
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"
cp app/build/outputs/apk/release/app-release.apk "fdroid/repo/agendula_${SAFE_REF_NAME}.apk"
- name: Copy metadata to F-Droid repo
run: |
@@ -248,7 +252,7 @@ jobs:
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"
CL_DIR="fdroid/metadata/de.jeanlucmakiola.agendula/en-US/changelogs"
mkdir -p "$CL_DIR"
cp /tmp/changelog.txt "$CL_DIR/${VERSION_CODE}.txt"
echo "Wrote $CL_DIR/${VERSION_CODE}.txt"
@@ -322,6 +326,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
- name: Extract changelog section for this tag
run: |

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

@@ -6,6 +6,56 @@ the source of truth for version codes (see Calendula's `docs/RELEASING.md`).
## [Unreleased]
### Added
- M5 reminders onboarding & polish: a one-time reminder onboarding step after the
provider grant explains that Agendula delivers due reminders itself and requests
`POST_NOTIFICATIONS` (API 33+). A new **Settings** screen (gear on the lists
overview), structured after Calendula as a category hub with sliding sub-screens
(`CollapsingScaffold` + grouped category rows + full-screen `OptionPicker`):
an About card, **Appearance** (theme, dynamic colour), **Task form** (which
optional edit-form fields show by default, default list, the add-a-subtask-row
opt-out), and **Reminders** (a master enable switch that re-requests the
notification permission, a "when to remind" default offset, and — on Android 12
only — an exact-alarm status row that deep-links to system settings).
- The reminders master switch gates the whole engine: turning it off clears every
scheduled alarm and suppresses any that fire.
- Expressive top-bar actions: a reusable `ShapedActionButton` wraps an action icon
in a tonal M3 Expressive `MaterialShapes` container (the settings entry uses the
faceted Gem shape, tertiary tones), with a small press scale/rotate flourish.
Future top-bar actions pick their own shape from the `ActionShapes` registry.
## [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
### 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
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
@@ -80,7 +80,7 @@ push, so run these locally before opening a PR. Keep CI green.
## 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
(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

View File

@@ -1,6 +1,6 @@
<div align="center">
<h1>Floret</h1>
<h1>Agendula</h1>
<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
@@ -13,14 +13,16 @@ sync stack.</p>
</div>
Floret 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
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`, Agendula is
a pure front-end over the **OpenTasks `TaskContract` provider** — the store that
DAVx5 (and SmoothSync, DecSync, …) syncs your CalDAV `VTODO` tasks into. No own
database, no reinvented sync.
A Calendula flower head is botanically made of many small *florets* — the
individual items that make up the bloom. Floret is those items: your tasks.
The name rhymes with its sibling on purpose: **Agendula** is *agenda* — Latin for
“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
> 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)
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
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

View File

@@ -16,11 +16,11 @@ val keystoreProperties = Properties().apply {
}
android {
namespace = "de.jeanlucmakiola.floret"
namespace = "de.jeanlucmakiola.agendula"
compileSdk = 37
defaultConfig {
applicationId = "de.jeanlucmakiola.floret"
applicationId = "de.jeanlucmakiola.agendula"
minSdk = 29
targetSdk = 36
// The git tag is the single source of truth for released builds: at
@@ -28,8 +28,8 @@ android {
// the tag, with versionCode = MAJOR*10000 + MINOR*100 + PATCH
// (e.g. v2.0.0 -> 20000). These committed values are the dev/local
// default; keep them matching the latest released tag. See docs/RELEASING.md.
versionCode = 100
versionName = "0.1.0"
versionCode = 200
versionName = "0.2.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -110,6 +110,7 @@ dependencies {
implementation(libs.hilt.android)
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.androidx.navigation.compose)
ksp(libs.hilt.compiler)
implementation(libs.androidx.datastore.preferences)
@@ -119,6 +120,12 @@ dependencies {
implementation(libs.kotlinx.datetime)
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.test.manifest)

View File

@@ -30,15 +30,16 @@
</queries>
<application
android:name=".FloretApp"
android:name=".AgendulaApp"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:localeConfig="@xml/locales_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Floret"
android:theme="@style/Theme.Agendula"
tools:targetApi="35">
<activity
android:name=".MainActivity"
@@ -51,6 +52,14 @@
</intent-filter>
</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). -->
<receiver
android:name=".data.reminders.DueReminderReceiver"
@@ -76,6 +85,17 @@
<data android:scheme="content" android:host="org.dmfs.tasks" />
</intent-filter>
</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>
</manifest>

View File

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.floret
package de.jeanlucmakiola.agendula
import android.app.Application
import dagger.hilt.EntryPoint
@@ -6,22 +6,35 @@ import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.android.HiltAndroidApp
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.Dispatchers
import kotlinx.coroutines.SupervisorJob
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),
* so alarms reflect tasks synced while the app was closed.
*/
@HiltAndroidApp
class FloretApp : Application() {
class AgendulaApp : Application() {
override fun 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
.fromApplication(this, ReminderEntryPoint::class.java)
.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.floret.data.tasks.TasksRepository
import de.jeanlucmakiola.floret.domain.DayWindow
import de.jeanlucmakiola.floret.domain.Priority
import de.jeanlucmakiola.floret.domain.TaskForm
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.floret.time.DayWindow
import de.jeanlucmakiola.agendula.domain.Priority
import de.jeanlucmakiola.agendula.domain.TaskForm
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
@@ -16,10 +16,10 @@ import kotlin.time.Instant
/**
* 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
* 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
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 = "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 = "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)))
repository.setCompleted(done, completed = true)
}
private companion object {
const val DEMO_LIST = "Floret Demo"
const val DEMO_LIST = "Agendula Demo"
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 androidx.datastore.core.DataStore
@@ -10,16 +10,16 @@ import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.floret.data.tasks.AndroidTasksDataSource
import de.jeanlucmakiola.floret.data.tasks.TasksDataSource
import de.jeanlucmakiola.floret.data.tasks.TasksRepository
import de.jeanlucmakiola.floret.data.tasks.TasksRepositoryImpl
import de.jeanlucmakiola.agendula.data.tasks.AndroidTasksDataSource
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.data.tasks.TasksRepositoryImpl
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import javax.inject.Singleton
private val Context.floretDataStore: DataStore<Preferences> by preferencesDataStore(
name = "floret_prefs",
private val Context.agendulaDataStore: DataStore<Preferences> by preferencesDataStore(
name = "agendula_prefs",
)
@Module
@@ -42,7 +42,7 @@ object DataProvideModule {
@Provides
@Singleton
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
context.floretDataStore
context.agendulaDataStore
@Provides
@IoDispatcher

View File

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.floret.data.di
package de.jeanlucmakiola.agendula.data.di
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.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.Context
import android.content.Intent
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.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -21,6 +23,7 @@ class DueReminderReceiver : BroadcastReceiver() {
@Inject lateinit var dataSource: TasksDataSource
@Inject lateinit var notifier: TaskNotifier
@Inject lateinit var settingsPrefs: SettingsPrefs
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@@ -30,6 +33,7 @@ class DueReminderReceiver : BroadcastReceiver() {
val pending = goAsync()
scope.launch {
try {
if (!settingsPrefs.settings.first().remindersEnabled) return@launch
val task = runCatching { dataSource.task(taskId) }.getOrNull()
if (task != null && !task.isClosed) notifier.postDue(task)
} finally {
@@ -39,7 +43,7 @@ class DueReminderReceiver : BroadcastReceiver() {
}
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 =
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.Context
@@ -12,7 +12,7 @@ import javax.inject.Inject
/**
* 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
* 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.PendingIntent
import android.content.Context
import android.os.Build
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.floret.data.di.IoDispatcher
import de.jeanlucmakiola.floret.data.prefs.SettingsPrefs
import de.jeanlucmakiola.floret.data.tasks.ProviderResolver
import de.jeanlucmakiola.floret.data.tasks.TaskQuery
import de.jeanlucmakiola.floret.data.tasks.TasksDataSource
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
@@ -18,7 +18,7 @@ import javax.inject.Singleton
/**
* 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
* change; it diffs against [ScheduledReminderStore] so only changed alarms move.
*/
@@ -33,11 +33,11 @@ class ReminderScheduler @Inject constructor(
) {
suspend fun sync() = withContext(io) {
val provider = providerResolver.resolve()
if (provider == null || !providerResolver.hasPermission(provider)) {
val settings = settingsPrefs.settings.first()
if (provider == null || !providerResolver.hasPermission(provider) || !settings.remindersEnabled) {
clearAll()
return@withContext
}
val lead = settingsPrefs.settings.first().reminderLeadMinutes.coerceAtLeast(0)
val now = System.currentTimeMillis()
val horizon = now + WINDOW_MS
val tasks = runCatching { dataSource.tasks(TaskQuery(includeCompleted = false)) }
@@ -45,7 +45,13 @@ class ReminderScheduler @Inject constructor(
val desired = tasks
.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 }
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.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.annotation.SuppressLint
@@ -12,9 +12,9 @@ import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import de.jeanlucmakiola.floret.MainActivity
import de.jeanlucmakiola.floret.R
import de.jeanlucmakiola.floret.domain.Task
import de.jeanlucmakiola.agendula.MainActivity
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.domain.Task
import dagger.hilt.android.qualifiers.ApplicationContext
import java.time.Instant as JInstant
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.ContentUris
@@ -9,12 +9,12 @@ import android.net.Uri
import android.os.Handler
import android.os.Looper
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Instances
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.floret.domain.Task
import de.jeanlucmakiola.floret.domain.TaskForm
import de.jeanlucmakiola.floret.domain.TaskList
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskList
import java.time.ZoneId
import javax.inject.Inject
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

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. */
class ProviderUnavailableException :
@@ -7,3 +7,10 @@ class ProviderUnavailableException :
/** A ContentResolver write returned no URI or affected no rows. */
class TaskWriteFailedException(operation: String) :
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.pm.PackageManager
@@ -8,7 +8,7 @@ import javax.inject.Inject
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.
*/
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.floret.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.floret.domain.Task
import de.jeanlucmakiola.floret.domain.TaskList
import de.jeanlucmakiola.floret.domain.priorityFromICal
import de.jeanlucmakiola.floret.domain.statusFromInt
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.priorityFromICal
import de.jeanlucmakiola.agendula.domain.statusFromInt
import kotlin.time.Instant
/** 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.floret.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
/** Column lists requested from the provider. Order is irrelevant; we read by name. */
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.floret.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.floret.domain.TaskForm
import de.jeanlucmakiola.floret.domain.toICal
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.toICal
/**
* 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.DESCRIPTION, form.description?.trim()?.ifBlank { null })
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.DTSTART, form.start?.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
/**
* 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`
* (Apache-2.0, dmfs GmbH):
* 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 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_TYPE = "org.dmfs.account.LOCAL"
@@ -85,7 +85,7 @@ object TasksContract {
* The `instances` view one row per task occurrence, with recurrence
* resolved and absolute [INSTANCE_START]/[INSTANCE_DUE] timestamps. Inherits
* 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 {
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.floret.domain.TaskForm
import de.jeanlucmakiola.floret.domain.TaskList
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskList
/** What to fetch from the provider. Smart-list date logic is applied above this. */
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.floret.domain.DayWindow
import de.jeanlucmakiola.floret.domain.SmartList
import de.jeanlucmakiola.floret.domain.Task
import de.jeanlucmakiola.floret.domain.TaskDetail
import de.jeanlucmakiola.floret.domain.TaskFilter
import de.jeanlucmakiola.floret.domain.TaskFiltering
import de.jeanlucmakiola.floret.domain.TaskForm
import de.jeanlucmakiola.floret.domain.TaskList
import de.jeanlucmakiola.floret.domain.TaskSorting
import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.floret.time.DayWindow
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskDetail
import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskFiltering
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.TaskSorting
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.awaitClose
@@ -23,6 +22,7 @@ import java.time.ZoneId
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Clock
import kotlin.time.Instant
@Singleton
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 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 {
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> {
val query = when (filter) {
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())
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) }
.map { task ->
progress[task.taskId]?.let { (done, total) ->
task.copy(subtaskDone = done, subtaskTotal = total)
} ?: task
}
.sortedWith(TaskSorting.DEFAULT)
}
override suspend fun createTask(form: TaskForm): Long =
withContext(io) { dataSource.insertTask(form) }
override suspend fun updateTask(taskId: Long, form: TaskForm) =
withContext(io) { dataSource.updateTask(taskId, form) }
override suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant?) =
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) =
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
/** A task list (the `tasklists` table). Lists group under their account. */
@@ -14,7 +14,7 @@ data class TaskList(
val isVisible: Boolean,
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
}
@@ -52,6 +52,13 @@ data class Task(
val distanceFromCurrent: Int?,
val created: 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 isClosed: Boolean get() = status == TaskStatus.COMPLETED || status == TaskStatus.CANCELLED
@@ -60,10 +67,11 @@ data class Task(
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(
val task: Task,
val subtasks: List<Task>,
val parent: Task? = null,
)
// --- 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

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. */
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.result.contract.ActivityResultContracts
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
@@ -17,15 +18,16 @@ import androidx.compose.ui.res.stringResource
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.data.tasks.ProviderStatus
import de.jeanlucmakiola.floret.ui.lists.ListsScreen
import de.jeanlucmakiola.floret.ui.permission.PermissionViewModel
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.data.tasks.ProviderStatus
import de.jeanlucmakiola.agendula.ui.navigation.AgendulaNavHost
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
* ([ListsScreen]). Navigation to the task-list / edit screens is wired as those
* screens land (next milestones); the callbacks are stubs for now.
* App root: gates on the tasks-provider permission, then hands off to
* [AgendulaNavHost] (lists task list detail / edit).
*/
@Composable
fun RootScreen(
@@ -50,11 +52,31 @@ fun RootScreen(
action = stringResource(R.string.onboarding_permission_button),
onAction = { launcher.launch(permission.permissionsToRequest.toTypedArray()) },
)
ProviderStatus.READY -> ListsScreen(
ProviderStatus.READY -> ReadyGate(modifier = modifier)
}
}
/**
* 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,
onOpenFilter = { /* TODO: navigate to TaskListScreen */ },
onNewTask = { /* TODO: navigate to TaskEditScreen */ },
)
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.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Checklist
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.draw.clip
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
/**
* 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
* 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
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,85 @@
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 icon down a touch and gives it a small turn — a light
* expressive flourish, no library motion APIs needed.
*/
@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,
) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.88f else 1f, label = "actionScale")
val rotation by animateFloatAsState(if (pressed) 24f else 0f, label = "actionRotation")
Surface(
onClick = onClick,
modifier = modifier.size(size),
shape = shape.toShape(),
color = containerColor,
contentColor = contentColor,
interactionSource = interaction,
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = icon,
contentDescription = contentDescription,
modifier = Modifier
.size(iconSize)
.scale(scale)
.rotate(rotation),
)
}
}
}
/**
* 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
}

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.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.TaskDetail
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskDetail
import de.jeanlucmakiola.agendula.domain.TaskForm
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -54,4 +55,15 @@ class TaskDetailViewModel @Inject constructor(
fun delete(task: Task) = viewModelScope.launch {
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

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.floret.ui.lists
package de.jeanlucmakiola.agendula.ui.lists
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -20,6 +20,7 @@ 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.Settings
import androidx.compose.material.icons.rounded.Today
import androidx.compose.material.icons.rounded.Upcoming
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -44,12 +45,14 @@ 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
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.domain.SmartList
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.floret.components.positionOf
/**
* Home: smart lists (Today / Overdue / Upcoming / All) as tonal cards with live
@@ -61,6 +64,7 @@ import de.jeanlucmakiola.floret.ui.common.positionOf
fun ListsScreen(
onOpenFilter: (TaskFilter) -> Unit,
onNewTask: () -> Unit,
onOpenSettings: () -> Unit,
modifier: Modifier = Modifier,
viewModel: ListsViewModel = hiltViewModel(),
) {
@@ -72,6 +76,17 @@ fun ListsScreen(
topBar = {
MediumTopAppBar(
title = { Text(stringResource(R.string.app_name)) },
actions = {
ShapedActionButton(
shape = ActionShapes.Settings,
icon = Icons.Rounded.Settings,
contentDescription = stringResource(R.string.settings_title),
onClick = onOpenSettings,
modifier = Modifier.padding(end = 8.dp),
size = 48.dp,
iconSize = 26.dp,
)
},
scrollBehavior = scrollBehavior,
)
},

View File

@@ -1,15 +1,15 @@
package de.jeanlucmakiola.floret.ui.lists
package de.jeanlucmakiola.agendula.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 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
@@ -49,16 +49,20 @@ class ListsViewModel @Inject constructor(
private fun buildContent(lists: List<TaskList>, openTasks: 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) =
openTasks.count { TaskFiltering.matches(it, TaskFilter.Smart(smart), todayStart, todayEnd) }
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, openTasks.size),
SmartCount(SmartList.ALL, topLevel.size),
)
val openByList = openTasks.groupingBy { it.listId }.eachCount()
val openByList = topLevel.groupingBy { it.listId }.eachCount()
val groups = lists
.groupBy { it.accountName }
.map { (account, accountLists) ->

View File

@@ -0,0 +1,125 @@
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)) },
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 dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.floret.data.tasks.ProviderResolver
import de.jeanlucmakiola.floret.data.tasks.ProviderStatus
import de.jeanlucmakiola.floret.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.ProviderStatus
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
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.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.floret.data.prefs.Settings
import de.jeanlucmakiola.floret.data.prefs.SettingsPrefs
import de.jeanlucmakiola.floret.data.prefs.ThemeMode
import de.jeanlucmakiola.floret.data.tasks.TasksRepository
import de.jeanlucmakiola.floret.domain.TaskList
import de.jeanlucmakiola.agendula.data.prefs.Settings
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
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.StateFlow
import kotlinx.coroutines.flow.catch
@@ -40,4 +42,17 @@ class SettingsViewModel @Inject constructor(
fun setDynamicColor(enabled: Boolean) = viewModelScope.launch { prefs.setDynamicColor(enabled) }
fun setDefaultList(id: Long?) = viewModelScope.launch { prefs.setDefaultListId(id) }
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.lightColorScheme
@@ -12,14 +12,14 @@ import androidx.compose.ui.graphics.Color
*
* 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
* 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),
onPrimary = Color(0xFFFFFFFF),
primaryContainer = Color(0xFFF6D9E5),
@@ -32,7 +32,7 @@ val FloretLightFallback = lightColorScheme(
onSurface = Color(0xFF1F1A1C),
)
val FloretDarkFallback = darkColorScheme(
val AgendulaDarkFallback = darkColorScheme(
primary = Color(0xFFE2BAC9),
onPrimary = Color(0xFF402533),
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
@@ -7,4 +7,4 @@ import androidx.compose.material3.Typography
* land in a later UI-design iteration; the defaults keep the M0 foundation lean
* (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,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,10 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Floret launcher icon foreground — PLACEHOLDER.
Agendula launcher icon foreground — PLACEHOLDER.
A simple rounded check mark inside the 108dp adaptive-icon canvas
(72dp safe zone). Deliberately not Calendula's calendar mark, so the two
apps never look alike. Replace with real branding (a stylized floret)
apps never look alike. Replace with real branding (a stylized agendula)
when the design lands — see docs/PLAN.md §9.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"

View File

@@ -1,9 +1,122 @@
<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>
<!-- Generic -->
<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 -->
<string name="lists_header">Lists</string>
@@ -23,10 +136,86 @@
<!-- Provider / permission onboarding -->
<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_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_install_opentasks">Install OpenTasks</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>

View File

@@ -1,5 +1,5 @@
<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:navigationBarColor">@android:color/transparent</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. */
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 de.jeanlucmakiola.floret.data.tasks.TasksContract.Instances
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.floret.domain.Priority
import de.jeanlucmakiola.floret.domain.TaskStatus
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.agendula.domain.Priority
import de.jeanlucmakiola.agendula.domain.TaskStatus
import org.junit.jupiter.api.Test
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 de.jeanlucmakiola.floret.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.floret.domain.Priority
import de.jeanlucmakiola.floret.domain.TaskForm
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.agendula.domain.Priority
import de.jeanlucmakiola.agendula.domain.TaskForm
import org.junit.jupiter.api.Test
import kotlin.time.Instant
@@ -28,6 +28,50 @@ class TaskWriteMapperTest {
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
fun `all-day task clears the timezone`() {
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 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 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 org.junit.jupiter.api.Test
@@ -35,4 +35,13 @@ class TaskFormTest {
val errors = TaskForm(title = "x", listId = 1, reminderMinutesBeforeDue = 10).validate()
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 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

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

@@ -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
[`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
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
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),
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
Single `:app` module (Posture A). Package root `de.jeanlucmakiola.floret`.
Single `:app` module (Posture A). Package root `de.jeanlucmakiola.agendula`.
| Package | Contents |
|---|---|
@@ -80,7 +80,7 @@ Single `:app` module (Posture A). Package root `de.jeanlucmakiola.floret`.
| `data/di/` | `DataModule` (binds + provides), `Qualifiers` (`@IoDispatcher`). |
| `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`. |
| root | `FloretApp` (Hilt app), `MainActivity`. |
| root | `AgendulaApp` (Hilt app), `MainActivity`. |
---
@@ -106,7 +106,7 @@ runtime perms for the active provider.
### 4.2 `TasksContract`
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
`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
the result as a `Flow`. A `ContentObserver` on the active authority's
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.
### 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
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
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
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
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.
---
@@ -228,8 +228,8 @@ checkbox/FAB/swipe motion).
Hilt, `SingletonComponent`. `DataModule` has a `@Binds` module
(`TasksDataSource``AndroidTasksDataSource`, `TasksRepository`
`TasksRepositoryImpl`) and a `@Provides` module (the `floret_prefs` DataStore,
the `@IoDispatcher`). `FloretApp` is the `@HiltAndroidApp` entry point;
`TasksRepositoryImpl`) and a `@Provides` module (the `agendula_prefs` DataStore,
the `@IoDispatcher`). `AgendulaApp` is the `@HiltAndroidApp` entry point;
`MainActivity` is `@AndroidEntryPoint`. ViewModels get the repository injected.
---

View File

@@ -1,23 +1,25 @@
# Floret — implementation plan
# Agendula — implementation plan
> A modern Material 3 Expressive **task** app for Android. Reads, writes, and
> reminds — on top of an existing tasks provider (synced by DAVx5 / SmoothSync /
> DecSync over CalDAV), with no own sync stack.
>
> Sibling to **Calendula**. Calendula is a calendar over `CalendarContract`;
> Floret is a to-do list over the **OpenTasks `TaskContract` provider**. A
> Calendula flower head is made of many small *florets* — the individual items
> that make up the bloom.
> Agendula is a to-do list over the **OpenTasks `TaskContract` provider**. The
> name mirrors Calendula's: *agenda* (Latin, “things to be done”) + the `-ula`
> 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):
`applicationId = de.jeanlucmakiola.floret`, app name **Floret**.
Identifiers: `applicationId = de.jeanlucmakiola.agendula`, app name **Agendula**
(renamed from the working title *Floret*, which was promoted to the shared
family / design-language name).
---
## 0. The thesis this app embodies
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**,
so we depend on a tasks *provider app* being present — exactly as Calendula
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**:
| 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 |
| 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
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"
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
Single `:app` module for A (mirrors Calendula). Package root
`de.jeanlucmakiola.floret`.
`de.jeanlucmakiola.agendula`.
```
domain/
@@ -111,10 +113,10 @@ ui/
edit/ TaskEditScreen + VM + UiState
settings/ (copy + adapt)
permission/ provider-presence + permission + POST_NOTIFICATIONS onboarding
FloretHost.kt nav host (mirrors CalendarHost)
AgendulaHost.kt nav host (mirrors CalendarHost)
RootScreen.kt
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:
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".
(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
(`org.dmfs.permission.*` etc.). Declared in the manifest *and* requested at
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
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` /
`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
- **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`
+ repository. Lists overview + task list render real synced tasks (read-only),
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
1. **Name**`Floret` is the working title (florets compose a Calendula head).
Confirm or replace before M0 (touches appId, namespace, package).
1. ~~**Name**~~**Agendula** (final): *agenda* + Calendula's `-ula`. Renamed
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
(`org.dmfs.tasks`) is the certain target for v1.
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
two), but a conscious choice.
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,
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

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
over CalDAV), with no own database or sync stack. Sibling to
[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 |
|---|---|
| [`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. |
| [`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. |

View File

@@ -1,6 +1,6 @@
# 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 a
human-readable **Gitea release** per tag. Both are produced automatically by
`.gitea/workflows/release.yaml` when you push a tag. There are no APK assets on
the Gitea release itself — distribution lives in the F-Droid repo; the release is
@@ -94,7 +94,7 @@ Keep this green before tagging.
## F-Droid metadata
App store listing lives in `fdroid-metadata/` (`de.jeanlucmakiola.floret.yml`
App store listing lives in `fdroid-metadata/` (`de.jeanlucmakiola.agendula.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

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
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)
The full non-visual stack ("backoffice") over the OpenTasks `TaskContract`
provider is **done and unit-tested**; the Material 3 Expressive UI is being
built screen by screen on top of it. App builds, gates on provider/permission,
and renders the lists overview.
provider is **done and unit-tested**, and the Material 3 Expressive UI is built
through **M5**: lists → task list (swipe gestures, inline add, smart-list section
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
day windows.
### 🚧 M2 — Screens: lists, task list, complete & CRUD
Replace the functional scaffold with the real Material 3 Expressive UI, screen
by screen, against the already-built ViewModels.
### M2 — Screens: lists, task list, complete & CRUD
The real Material 3 Expressive UI, built screen by screen against the M1
ViewModels.
- ✅ Provider/permission onboarding gate (`RootScreen`).
- ✅ Lists overview (`ListsScreen`) — smart lists + user lists grouped by account.
- 🚧 Navigation host wiring (the `onOpenFilter` / `onNewTask` callbacks in
`RootScreen` are currently stubs).
- Task list screen — checkbox rows, swipe-to-complete / swipe-to-delete,
inline add field, section headers for smart lists, FAB.
- ⬜ Toggle complete (swipe + checkbox), create / edit / delete, local-list
creation wired to the UI.
- Navigation host wiring (`AgendulaNavHost` + `Dest` route table: lists → task
list → detail / edit, each binding its M1 ViewModel from route args).
- Task list screen — checkbox rows + toggle-complete, swipe-to-complete /
-delete (`SwipeToDismissBox`), inline add field (`InlineAdd``quickAdd`),
smart-list section headers (`TaskSections`).
- ✅ 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
Task detail and edit screens: due/start date-time pickers, priority,
percent-complete, conflict-safe saves (re-check before overwrite), smart-list
section presentation.
### M3 — Detail / edit polish
- ✅ Due / start date-time pickers (`DateTimePickerFlow` in `TaskEditScreen`).
- ✅ Priority — coloured by level: green / amber / red pastels (`priorityFill`;
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)
`RELATED-TO` hierarchy in the UI: indentation, create subtask, reparent. The
data layer already reads `parentId`; this is the trickiest UI piece.
### M4 — Subtasks (UI)
`RELATED-TO` hierarchy in the UI. The data layer already reads `parentId`.
- ✅ 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
The engine exists (M1). Remaining: the `POST_NOTIFICATIONS` + exact-alarm
onboarding flow, per-task / default reminder-offset settings UI, and
end-to-end verification on device.
### M5 — Reminders onboarding & polish
The engine exists (M1: `ReminderScheduler` + boot / provider-change re-sync,
`DueReminderReceiver`, `TaskNotifier`).
- ✅ 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
Settings screen (theme / dynamic-color / language, default list, default
reminder), Glance task-list widget, translations, finalize F-Droid metadata,
confirm CI release flow.
### 🚧 M6 — Settings, widget, i18n, release
- ✅ F-Droid metadata scaffolded (`fdroid-metadata/`).
- ✅ Settings screen — landed early with M5 (`SettingsScreen` in the nav graph,
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)
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.
1. ~~**Name** — `Floret`~~ confirmed (appId `de.jeanlucmakiola.floret`).
1. ~~**Name** — `Agendula`~~ confirmed (appId `de.jeanlucmakiola.agendula`).
2. ~~**tasks.org provider authority**~~ verified on device:
`org.tasks.opentasks` + `org.tasks.permission.*`.
3. **jtx Board** — support its richer contract later, or stay OpenTasks-only?
(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
conscious choice.
5. **Recurring tasks** — read as occurrences today (`isRecurring` flag exists);

View File

@@ -0,0 +1,10 @@
AuthorName: Jean-Luc Makiola
License: MIT
Name: Agendula
Summary: A modern Material 3 Expressive task app for Android.
Categories:
- Time
SourceCode: https://gitea.jeanlucmakiola.de/makiolaj/agendula
IssueTracker: https://gitea.jeanlucmakiola.de/makiolaj/agendula/issues

View File

@@ -1,4 +1,4 @@
Floret is a modern, open-source task app for Android. It works directly on an
Agendula is a modern, open-source task app for Android. It works directly on an
existing tasks provider (OpenTasks / tasks.org), so any CalDAV tasks synced to
your device via DAVx5, SmoothSync or DecSync show up automatically, and changes
you make sync back the same way — no own account, no own sync.

View File

@@ -1,10 +0,0 @@
AuthorName: Jean-Luc Makiola
License: MIT
Name: Floret
Summary: A modern Material 3 Expressive task app for Android.
Categories:
- Time
SourceCode: https://gitea.jeanlucmakiola.de/makiolaj/floret
IssueTracker: https://gitea.jeanlucmakiola.de/makiolaj/floret/issues

1
floret-kit Submodule

Submodule floret-kit added at 566caf4305

View File

@@ -22,6 +22,7 @@ kotlinxDatetime = "0.7.0"
kotlinxCoroutines = "1.10.2"
turbine = "1.2.0"
hiltNavigationCompose = "1.3.0"
navigationCompose = "2.9.0"
lifecycleCompose = "2.10.0"
androidxTestRules = "1.7.0"
# Glance: 1.1.1 is the latest stable (1.2.0 is still rc, 1.3.0 alpha).
@@ -78,6 +79,9 @@ turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine
# Hilt navigation-compose (for hiltViewModel() in Composables)
androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
# Navigation-compose (the NavHost / back stack)
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
# Lifecycle compose (for collectAsStateWithLifecycle)
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleCompose" }

View File

@@ -11,9 +11,10 @@ pluginManagement {
gradlePluginPortal()
}
}
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}
// No Gradle Java-toolchain auto-download resolver plugin: it can fetch a JDK at
// build time, which an offline / reproducible F-Droid build scanner rejects.
// Modules set jvmTarget directly, so no toolchain resolver is needed.
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
@@ -23,5 +24,6 @@ dependencyResolutionManagement {
}
}
rootProject.name = "Floret"
rootProject.name = "Agendula"
include(":app")
includeBuild("floret-kit")