20 Commits
v0.2.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
97 changed files with 2385 additions and 1074 deletions

View File

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

View File

@@ -15,6 +15,8 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup Java - name: Setup Java
uses: actions/setup-java@v4 uses: actions/setup-java@v4
@@ -73,6 +75,8 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup Java - name: Setup Java
uses: actions/setup-java@v4 uses: actions/setup-java@v4
@@ -225,7 +229,7 @@ jobs:
if [ -z "$SAFE_REF_NAME" ]; then if [ -z "$SAFE_REF_NAME" ]; then
SAFE_REF_NAME="${GITHUB_SHA:-manual}" SAFE_REF_NAME="${GITHUB_SHA:-manual}"
fi 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 - name: Copy metadata to F-Droid repo
run: | run: |
@@ -248,7 +252,7 @@ jobs:
if [ ! -s /tmp/changelog.txt ]; then if [ ! -s /tmp/changelog.txt ]; then
echo "See CHANGELOG.md for $VERSION." > /tmp/changelog.txt echo "See CHANGELOG.md for $VERSION." > /tmp/changelog.txt
fi 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" mkdir -p "$CL_DIR"
cp /tmp/changelog.txt "$CL_DIR/${VERSION_CODE}.txt" cp /tmp/changelog.txt "$CL_DIR/${VERSION_CODE}.txt"
echo "Wrote $CL_DIR/${VERSION_CODE}.txt" echo "Wrote $CL_DIR/${VERSION_CODE}.txt"
@@ -322,6 +326,8 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
submodules: recursive
- name: Extract changelog section for this tag - name: Extract changelog section for this tag
run: | 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,24 @@ the source of truth for version codes (see Calendula's `docs/RELEASING.md`).
## [Unreleased] ## [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 ## [0.2.0] - 2026-06-27
### Added ### Added

View File

@@ -1,6 +1,6 @@
# Contributing to Floret # Contributing to Agendula
Thanks for your interest in Floret — a Material 3 Expressive task app that's a Thanks for your interest in Agendula — a Material 3 Expressive task app that's a
pure front-end over the OpenTasks `TaskContract` provider, with no own database pure front-end over the OpenTasks `TaskContract` provider, with no own database
or sync stack. Before diving in, skim [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) or sync stack. Before diving in, skim [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)
(how it's built), [`docs/ROADMAP.md`](docs/ROADMAP.md) (what's next), and (how it's built), [`docs/ROADMAP.md`](docs/ROADMAP.md) (what's next), and
@@ -80,7 +80,7 @@ push, so run these locally before opening a PR. Keep CI green.
## Scope ## Scope
Floret stays true to its thesis: a front-end over **open** task backends Agendula stays true to its thesis: a front-end over **open** task backends
(CalDAV / iCalendar / DecSync via the OpenTasks provider). Proprietary backends (CalDAV / iCalendar / DecSync via the OpenTasks provider). Proprietary backends
(Google Tasks, Microsoft To Do) are out of scope by design — they'd mean owning (Google Tasks, Microsoft To Do) are out of scope by design — they'd mean owning
a sync stack. v1 targets the OpenTasks contract (OpenTasks + tasks.org); jtx's a sync stack. v1 targets the OpenTasks contract (OpenTasks + tasks.org); jtx's

View File

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

View File

@@ -16,11 +16,11 @@ val keystoreProperties = Properties().apply {
} }
android { android {
namespace = "de.jeanlucmakiola.floret" namespace = "de.jeanlucmakiola.agendula"
compileSdk = 37 compileSdk = 37
defaultConfig { defaultConfig {
applicationId = "de.jeanlucmakiola.floret" applicationId = "de.jeanlucmakiola.agendula"
minSdk = 29 minSdk = 29
targetSdk = 36 targetSdk = 36
// The git tag is the single source of truth for released builds: at // The git tag is the single source of truth for released builds: at
@@ -120,6 +120,12 @@ dependencies {
implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.core)
implementation("de.jeanlucmakiola.floret:core-time")
implementation("de.jeanlucmakiola.floret:core-reminders")
implementation("de.jeanlucmakiola.floret:core-locale")
implementation("de.jeanlucmakiola.floret:core-crash")
implementation("de.jeanlucmakiola.floret:identity")
implementation("de.jeanlucmakiola.floret:components")
debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest) debugImplementation(libs.androidx.ui.test.manifest)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.floret.data.tasks package de.jeanlucmakiola.agendula.data.tasks
/** No tasks provider (OpenTasks / tasks.org) is installed on the device. */ /** No tasks provider (OpenTasks / tasks.org) is installed on the device. */
class ProviderUnavailableException : class ProviderUnavailableException :

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,9 @@
package de.jeanlucmakiola.floret.data.tasks package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Lists import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Tasks import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.floret.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.floret.domain.toICal import de.jeanlucmakiola.agendula.domain.toICal
/** /**
* Turns a [TaskForm] / mutation into a namevalue map. Pure (no ContentValues), * Turns a [TaskForm] / mutation into a namevalue map. Pure (no ContentValues),

View File

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

View File

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

View File

@@ -1,14 +1,14 @@
package de.jeanlucmakiola.floret.data.tasks package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.floret.domain.Task import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.floret.domain.TaskDetail import de.jeanlucmakiola.agendula.domain.TaskDetail
import de.jeanlucmakiola.floret.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.floret.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.floret.domain.TaskList import de.jeanlucmakiola.agendula.domain.TaskList
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlin.time.Instant import kotlin.time.Instant
/** Whether Floret can use the tasks provider right now. Drives onboarding. */ /** Whether Agendula can use the tasks provider right now. Drives onboarding. */
enum class ProviderStatus { READY, NEEDS_PERMISSION, NO_PROVIDER } enum class ProviderStatus { READY, NEEDS_PERMISSION, NO_PROVIDER }
/** /**
@@ -19,6 +19,13 @@ enum class ProviderStatus { READY, NEEDS_PERMISSION, NO_PROVIDER }
interface TasksRepository { interface TasksRepository {
fun taskLists(): Flow<List<TaskList>> fun taskLists(): Flow<List<TaskList>>
fun tasks(filter: TaskFilter): Flow<List<Task>> 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?> fun taskDetail(taskId: Long): Flow<TaskDetail?>
suspend fun createTask(form: TaskForm): Long suspend fun createTask(form: TaskForm): Long

View File

@@ -1,14 +1,14 @@
package de.jeanlucmakiola.floret.data.tasks package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.floret.data.di.IoDispatcher import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.floret.domain.DayWindow import de.jeanlucmakiola.floret.time.DayWindow
import de.jeanlucmakiola.floret.domain.Task import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.floret.domain.TaskDetail import de.jeanlucmakiola.agendula.domain.TaskDetail
import de.jeanlucmakiola.floret.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.floret.domain.TaskFiltering import de.jeanlucmakiola.agendula.domain.TaskFiltering
import de.jeanlucmakiola.floret.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.floret.domain.TaskList import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.floret.domain.TaskSorting import de.jeanlucmakiola.agendula.domain.TaskSorting
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
@@ -35,6 +35,12 @@ class TasksRepositoryImpl @Inject constructor(
override fun tasks(filter: TaskFilter): Flow<List<Task>> = observing { loadTasks(filter) } override fun tasks(filter: TaskFilter): Flow<List<Task>> = observing { loadTasks(filter) }
override fun subtasks(parentId: Long): Flow<List<Task>> = observing {
dataSource.subtasks(parentId)
.filter { it.taskId != parentId }
.sortedWith(TaskSorting.DEFAULT)
}
override fun taskDetail(taskId: Long): Flow<TaskDetail?> = observing { override fun taskDetail(taskId: Long): Flow<TaskDetail?> = observing {
dataSource.task(taskId)?.let { task -> dataSource.task(taskId)?.let { task ->
TaskDetail( TaskDetail(

View File

@@ -1,6 +1,6 @@
package de.jeanlucmakiola.floret.domain package de.jeanlucmakiola.agendula.domain
import de.jeanlucmakiola.floret.data.tasks.TasksContract import de.jeanlucmakiola.agendula.data.tasks.TasksContract
import kotlin.time.Instant import kotlin.time.Instant
/** A task list (the `tasklists` table). Lists group under their account. */ /** A task list (the `tasklists` table). Lists group under their account. */
@@ -14,7 +14,7 @@ data class TaskList(
val isVisible: Boolean, val isVisible: Boolean,
val owner: String?, val owner: String?,
) { ) {
/** A device-only list Floret (or another app) created locally, not synced. */ /** A device-only list Agendula (or another app) created locally, not synced. */
val isLocal: Boolean get() = accountType == TasksContract.LOCAL_ACCOUNT_TYPE val isLocal: Boolean get() = accountType == TasksContract.LOCAL_ACCOUNT_TYPE
} }

View File

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

View File

@@ -1,11 +1,11 @@
package de.jeanlucmakiola.floret.domain package de.jeanlucmakiola.agendula.domain
import kotlin.time.Instant import kotlin.time.Instant
/** /**
* A validated create/edit form. Kept free of Android types so ViewModels and * 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 * tests can build and validate it on the JVM. The data layer turns it into
* provider ContentValues ([de.jeanlucmakiola.floret.data.tasks.TaskWriteMapper]). * provider ContentValues ([de.jeanlucmakiola.agendula.data.tasks.TaskWriteMapper]).
*/ */
data class TaskForm( data class TaskForm(
val title: String, val title: String,

View File

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

View File

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

View File

@@ -1,7 +1,8 @@
package de.jeanlucmakiola.floret.ui package de.jeanlucmakiola.agendula.ui
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -17,14 +18,16 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.floret.R import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.floret.data.tasks.ProviderStatus import de.jeanlucmakiola.agendula.data.tasks.ProviderStatus
import de.jeanlucmakiola.floret.ui.navigation.FloretNavHost import de.jeanlucmakiola.agendula.ui.navigation.AgendulaNavHost
import de.jeanlucmakiola.floret.ui.permission.PermissionViewModel import de.jeanlucmakiola.agendula.ui.permission.PermissionViewModel
import de.jeanlucmakiola.agendula.ui.permission.ReminderOnboardingScreen
import de.jeanlucmakiola.agendula.ui.permission.ReminderOnboardingViewModel
/** /**
* App root: gates on the tasks-provider permission, then hands off to * App root: gates on the tasks-provider permission, then hands off to
* [FloretNavHost] (lists task list detail / edit). * [AgendulaNavHost] (lists task list detail / edit).
*/ */
@Composable @Composable
fun RootScreen( fun RootScreen(
@@ -49,7 +52,31 @@ fun RootScreen(
action = stringResource(R.string.onboarding_permission_button), action = stringResource(R.string.onboarding_permission_button),
onAction = { launcher.launch(permission.permissionsToRequest.toTypedArray()) }, onAction = { launcher.launch(permission.permissionsToRequest.toTypedArray()) },
) )
ProviderStatus.READY -> FloretNavHost(modifier = modifier) 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,
)
null -> {}
}
} }
} }

View File

@@ -1,5 +1,6 @@
package de.jeanlucmakiola.floret.ui.common package de.jeanlucmakiola.agendula.ui.common
import de.jeanlucmakiola.floret.time.formatDateTime
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -31,7 +32,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import de.jeanlucmakiola.floret.R import de.jeanlucmakiola.agendula.R
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalTime import java.time.LocalTime
import java.time.ZoneId import java.time.ZoneId

View File

@@ -1,5 +1,6 @@
package de.jeanlucmakiola.floret.ui.common package de.jeanlucmakiola.agendula.ui.common
import de.jeanlucmakiola.floret.components.pastelize
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -22,24 +23,10 @@ import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
/**
* Soften a raw provider colour toward a pastel that fits the active theme
* keeps the hue (lists stay recognisable), caps saturation so harsh sync
* colours stop screaming, pins brightness to read on light and dark. Copied
* from Calendula so the families' colours behave identically.
*/
fun pastelize(rawArgb: Int, dark: Boolean): Color {
val hsv = FloatArray(3)
android.graphics.Color.colorToHSV(rawArgb, hsv)
hsv[1] = (hsv[1] * 0.6f).coerceIn(0.25f, 0.65f)
hsv[2] = if (dark) 0.82f else 0.72f
return Color(android.graphics.Color.HSVToColor(hsv))
}
/** /**
* Leading avatar for a task list: a neutral round chip holding a checklist glyph * Leading avatar for a task list: a neutral round chip holding a checklist glyph
* tinted in the list's (pastelised) colour the Calendula calendar-chip pattern, * tinted in the list's (pastelised) colour the Calendula calendar-chip pattern,
* with a task icon for Floret. * with a task icon for Agendula.
*/ */
@Composable @Composable
fun ListColorChip(color: Int, modifier: Modifier = Modifier) { fun ListColorChip(color: Int, modifier: Modifier = Modifier) {

View File

@@ -1,5 +1,6 @@
package de.jeanlucmakiola.floret.ui.common package de.jeanlucmakiola.agendula.ui.common
import de.jeanlucmakiola.floret.components.pastelize
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -21,7 +22,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.floret.domain.Priority import de.jeanlucmakiola.agendula.domain.Priority
/** /**
* Priority has no semantic Material role (M3 only ships `error` for danger), so * Priority has no semantic Material role (M3 only ships `error` for danger), so
@@ -44,6 +45,27 @@ fun priorityFill(priority: Priority, dark: Boolean): Color {
return Color(android.graphics.Color.HSVToColor(hsv)) 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], * 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 * filled in that level's pastel hue. Used read-only in the list and detail
@@ -90,14 +112,14 @@ fun PriorityChip(
val body: @Composable () -> Unit = { val body: @Composable () -> Unit = {
Row( Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp), modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
if (showIcon) { if (showIcon) {
Icon(Icons.Rounded.Flag, contentDescription = null, tint = iconTint, modifier = Modifier.size(15.dp)) Icon(Icons.Rounded.Flag, contentDescription = null, tint = iconTint, modifier = Modifier.size(14.dp))
} }
Text(label, style = MaterialTheme.typography.labelMedium, color = content) Text(label, style = MaterialTheme.typography.labelSmall, color = content)
} }
} }
val shape = RoundedCornerShape(50) val shape = RoundedCornerShape(50)

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

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.floret.ui.detail package de.jeanlucmakiola.agendula.ui.detail
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -67,16 +67,16 @@ import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.floret.R import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.floret.domain.Task import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.floret.domain.TaskDetail import de.jeanlucmakiola.agendula.domain.TaskDetail
import de.jeanlucmakiola.floret.ui.common.GroupedSurface import de.jeanlucmakiola.floret.components.GroupedSurface
import de.jeanlucmakiola.floret.ui.common.PriorityChip import de.jeanlucmakiola.agendula.ui.common.PriorityChip
import de.jeanlucmakiola.floret.ui.common.formatDate import de.jeanlucmakiola.floret.time.formatDate
import de.jeanlucmakiola.floret.ui.common.formatTime import de.jeanlucmakiola.floret.time.formatTime
import de.jeanlucmakiola.floret.ui.common.pastelize import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.floret.ui.common.positionOf import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.ui.tasklist.priorityLabel import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel
/** /**
* One task's detail. Each fact is its own tonal card with a leading icon (the * One task's detail. Each fact is its own tonal card with a leading icon (the
@@ -246,7 +246,7 @@ private fun DetailBody(
} }
// Priority — a coloured status chip in the level's hue. // Priority — a coloured status chip in the level's hue.
if (task.priority != de.jeanlucmakiola.floret.domain.Priority.NONE) { if (task.priority != de.jeanlucmakiola.agendula.domain.Priority.NONE) {
add { add {
DetailCard( DetailCard(
icon = Icons.Rounded.Flag, icon = Icons.Rounded.Flag,

View File

@@ -1,12 +1,12 @@
package de.jeanlucmakiola.floret.ui.detail package de.jeanlucmakiola.agendula.ui.detail
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.floret.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.floret.domain.Task import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.floret.domain.TaskDetail import de.jeanlucmakiola.agendula.domain.TaskDetail
import de.jeanlucmakiola.floret.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskForm
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted

View File

@@ -1,10 +1,5 @@
package de.jeanlucmakiola.floret.ui.edit package de.jeanlucmakiola.agendula.ui.edit
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
@@ -85,27 +80,28 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.floret.R import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.floret.domain.DayWindow import de.jeanlucmakiola.floret.time.DayWindow
import de.jeanlucmakiola.floret.domain.Priority import de.jeanlucmakiola.agendula.domain.Priority
import de.jeanlucmakiola.floret.domain.Task import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.floret.domain.TaskFormError import de.jeanlucmakiola.agendula.domain.TaskFormError
import de.jeanlucmakiola.floret.domain.TaskFormField import de.jeanlucmakiola.agendula.domain.TaskFormField
import de.jeanlucmakiola.floret.domain.TaskList import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.floret.domain.TaskSection import de.jeanlucmakiola.agendula.domain.TaskSection
import de.jeanlucmakiola.floret.domain.TaskSections import de.jeanlucmakiola.agendula.domain.TaskSections
import de.jeanlucmakiola.floret.ui.common.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.ui.common.InlineTextField import de.jeanlucmakiola.floret.components.OptionalFormSection
import de.jeanlucmakiola.floret.ui.common.OptionCard import de.jeanlucmakiola.floret.components.InlineTextField
import de.jeanlucmakiola.floret.ui.common.priorityFill import de.jeanlucmakiola.floret.components.OptionCard
import de.jeanlucmakiola.floret.ui.common.formatDate import de.jeanlucmakiola.agendula.ui.common.priorityFill
import de.jeanlucmakiola.floret.ui.common.formatTime import de.jeanlucmakiola.floret.time.formatDate
import de.jeanlucmakiola.floret.ui.common.localToInstant import de.jeanlucmakiola.floret.time.formatTime
import de.jeanlucmakiola.floret.ui.common.pastelize import de.jeanlucmakiola.agendula.ui.common.localToInstant
import de.jeanlucmakiola.floret.ui.common.positionOf import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.floret.ui.common.toLocalDate import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.ui.common.toLocalTime import de.jeanlucmakiola.agendula.ui.common.toLocalDate
import de.jeanlucmakiola.floret.ui.tasklist.priorityLabel import de.jeanlucmakiola.agendula.ui.common.toLocalTime
import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel
import java.time.LocalTime import java.time.LocalTime
import java.time.ZoneId import java.time.ZoneId
import java.time.ZoneOffset import java.time.ZoneOffset
@@ -119,7 +115,7 @@ import kotlin.time.Instant
* "When" card with the all-day toggle and tappable schedule rows, a tappable * "When" card with the all-day toggle and tappable schedule rows, a tappable
* list card, and the optional Description / Priority / Reminder sections that * list card, and the optional Description / Priority / Reminder sections that
* unfold from "More fields" (which ones start open is a setting). Validated * unfold from "More fields" (which ones start open is a setting). Validated
* through the M1 [TaskEditViewModel] / [de.jeanlucmakiola.floret.domain.TaskForm]; * through the M1 [TaskEditViewModel] / [de.jeanlucmakiola.agendula.domain.TaskForm];
* the VM flips `saved` once the write lands, which pops us back. * the VM flips `saved` once the write lands, which pops us back.
*/ */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -545,17 +541,6 @@ private fun EditContent(
* open instead of popping in. (Initially-visible sections render without * open instead of popping in. (Initially-visible sections render without
* animating on the first frame they're added.) * animating on the first frame they're added.)
*/ */
@Composable
private fun OptionalFormSection(visible: Boolean, content: @Composable ColumnScope.() -> Unit) {
AnimatedVisibility(
visible = visible,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(),
) {
Column(modifier = Modifier.fillMaxWidth(), content = content)
}
}
/** /**
* One info card mirroring the detail screen's card: tonal container, leading * 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. * icon in the gutter, value to the right. Optionally clickable as a whole.
@@ -853,7 +838,7 @@ private fun ParentPickerSheet(
item(key = "none") { item(key = "none") {
GroupedRow( GroupedRow(
title = stringResource(R.string.edit_parent_none), title = stringResource(R.string.edit_parent_none),
position = de.jeanlucmakiola.floret.ui.common.Position.Alone, position = de.jeanlucmakiola.floret.components.Position.Alone,
selected = selectedId == null, selected = selectedId == null,
minHeight = 56.dp, minHeight = 56.dp,
onClick = { choose(null) }, onClick = { choose(null) },

View File

@@ -1,20 +1,20 @@
package de.jeanlucmakiola.floret.ui.edit package de.jeanlucmakiola.agendula.ui.edit
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.floret.data.prefs.SettingsPrefs import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.floret.data.reminders.ReminderScheduler import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
import de.jeanlucmakiola.floret.data.tasks.TaskConflictException import de.jeanlucmakiola.agendula.data.tasks.TaskConflictException
import de.jeanlucmakiola.floret.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.floret.domain.Priority import de.jeanlucmakiola.agendula.domain.Priority
import de.jeanlucmakiola.floret.domain.Task import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.floret.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.floret.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.floret.domain.TaskFormError import de.jeanlucmakiola.agendula.domain.TaskFormError
import de.jeanlucmakiola.floret.domain.TaskFormField import de.jeanlucmakiola.agendula.domain.TaskFormField
import de.jeanlucmakiola.floret.domain.TaskList import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.floret.domain.populatedFields import de.jeanlucmakiola.agendula.domain.populatedFields
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow

View File

@@ -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.animation.core.animateDpAsState
import androidx.compose.foundation.interaction.MutableInteractionSource 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.automirrored.rounded.ListAlt
import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.ErrorOutline 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.Today
import androidx.compose.material.icons.rounded.Upcoming import androidx.compose.material.icons.rounded.Upcoming
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -44,12 +45,14 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.floret.R import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.floret.domain.SmartList import de.jeanlucmakiola.agendula.domain.SmartList
import de.jeanlucmakiola.floret.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.floret.ui.common.GroupedRow import de.jeanlucmakiola.agendula.ui.common.ActionShapes
import de.jeanlucmakiola.floret.ui.common.ListColorChip import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.ui.common.positionOf 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 * 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( fun ListsScreen(
onOpenFilter: (TaskFilter) -> Unit, onOpenFilter: (TaskFilter) -> Unit,
onNewTask: () -> Unit, onNewTask: () -> Unit,
onOpenSettings: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: ListsViewModel = hiltViewModel(), viewModel: ListsViewModel = hiltViewModel(),
) { ) {
@@ -72,6 +76,17 @@ fun ListsScreen(
topBar = { topBar = {
MediumTopAppBar( MediumTopAppBar(
title = { Text(stringResource(R.string.app_name)) }, 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, 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.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.floret.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.floret.domain.DayWindow import de.jeanlucmakiola.floret.time.DayWindow
import de.jeanlucmakiola.floret.domain.SmartList import de.jeanlucmakiola.agendula.domain.SmartList
import de.jeanlucmakiola.floret.domain.Task import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.floret.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.floret.domain.TaskFiltering import de.jeanlucmakiola.agendula.domain.TaskFiltering
import de.jeanlucmakiola.floret.domain.TaskList import de.jeanlucmakiola.agendula.domain.TaskList
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch

View File

@@ -1,10 +1,10 @@
package de.jeanlucmakiola.floret.ui.navigation package de.jeanlucmakiola.agendula.ui.navigation
import androidx.compose.animation.EnterTransition import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition import androidx.compose.animation.ExitTransition
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.scaleOut
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -12,14 +12,15 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import de.jeanlucmakiola.floret.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.floret.ui.detail.TaskDetailScreen import de.jeanlucmakiola.agendula.ui.detail.TaskDetailScreen
import de.jeanlucmakiola.floret.ui.detail.TaskDetailViewModel import de.jeanlucmakiola.agendula.ui.detail.TaskDetailViewModel
import de.jeanlucmakiola.floret.ui.edit.TaskEditScreen import de.jeanlucmakiola.agendula.ui.edit.TaskEditScreen
import de.jeanlucmakiola.floret.ui.edit.TaskEditViewModel import de.jeanlucmakiola.agendula.ui.edit.TaskEditViewModel
import de.jeanlucmakiola.floret.ui.lists.ListsScreen import de.jeanlucmakiola.agendula.ui.lists.ListsScreen
import de.jeanlucmakiola.floret.ui.tasklist.TaskListScreen import de.jeanlucmakiola.agendula.ui.settings.SettingsScreen
import de.jeanlucmakiola.floret.ui.tasklist.TaskListViewModel 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. * The single in-app back stack: lists task list detail / edit.
@@ -31,9 +32,8 @@ import de.jeanlucmakiola.floret.ui.tasklist.TaskListViewModel
* unaware of the nav library. * unaware of the nav library.
*/ */
@Composable @Composable
fun FloretNavHost(modifier: Modifier = Modifier) { fun AgendulaNavHost(modifier: Modifier = Modifier) {
val nav = rememberNavController() val nav = rememberNavController()
val slide = rememberNavSlideSpec()
NavHost( NavHost(
navController = nav, navController = nav,
@@ -41,20 +41,26 @@ fun FloretNavHost(modifier: Modifier = Modifier) {
modifier = modifier, modifier = modifier,
// Only the screen on top moves; the background is always held static. // 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. // Forward is a plain fade-in over the held screen — calm, not a slide.
// Back (and the predictive-back drag, which drives popExit) is the snappy // Back is the predictive-back peek: navigation-compose composes the
// slide-out to the right, revealing the held screen unmoved. // screen being revealed underneath and seeks popExit with the gesture, so
// the leaving screen scales down to reveal the (unmoved) previous screen.
enterTransition = { fadeIn() }, enterTransition = { fadeIn() },
exitTransition = { ExitTransition.None }, exitTransition = { ExitTransition.None },
popEnterTransition = { EnterTransition.None }, popEnterTransition = { EnterTransition.None },
popExitTransition = { slideOutHorizontally(slide) { it } + fadeOut() }, popExitTransition = { scaleOut(targetScale = 0.9f) + fadeOut() },
) { ) {
composable(Dest.LISTS) { composable(Dest.LISTS) {
ListsScreen( ListsScreen(
onOpenFilter = { filter -> nav.navigate(Dest.TaskList.build(filter)) }, onOpenFilter = { filter -> nav.navigate(Dest.TaskList.build(filter)) },
onNewTask = { nav.navigate(Dest.TaskEdit.buildNew()) }, 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 -> composable(Dest.TaskList.route, arguments = Dest.TaskList.arguments) { entry ->
val args = entry.arguments val args = entry.arguments
val filter = Dest.TaskList.filterOf( val filter = Dest.TaskList.filterOf(

View File

@@ -1,9 +1,9 @@
package de.jeanlucmakiola.floret.ui.navigation package de.jeanlucmakiola.agendula.ui.navigation
import androidx.navigation.NavType import androidx.navigation.NavType
import androidx.navigation.navArgument import androidx.navigation.navArgument
import de.jeanlucmakiola.floret.domain.SmartList import de.jeanlucmakiola.agendula.domain.SmartList
import de.jeanlucmakiola.floret.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.TaskFilter
/** /**
* String-based route table for the app's [androidx.navigation.NavHost]. * String-based route table for the app's [androidx.navigation.NavHost].
@@ -23,6 +23,9 @@ object Dest {
/** Home — smart lists + the user's lists. */ /** Home — smart lists + the user's lists. */
const val LISTS = "lists" const val LISTS = "lists"
/** App preferences. */
const val SETTINGS = "settings"
/** /**
* One [TaskFilter]'s tasks. Carries *either* `listId` (a real list) *or* * One [TaskFilter]'s tasks. Carries *either* `listId` (a real list) *or*
* `smart` (a [SmartList] name); whichever is set decides the filter. * `smart` (a [SmartList] name); whichever is set decides the filter.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.floret.ui.tasklist package de.jeanlucmakiola.agendula.ui.tasklist
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
@@ -21,6 +21,12 @@ import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.isImeVisible
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.union
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@@ -39,6 +45,7 @@ import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.Checklist import androidx.compose.material.icons.rounded.Checklist
import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.ExpandMore import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.material.icons.rounded.Flag
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.ExtendedFloatingActionButton
@@ -67,6 +74,7 @@ import kotlinx.coroutines.delay
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
@@ -80,20 +88,19 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.floret.R import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.floret.domain.DayWindow import de.jeanlucmakiola.floret.time.DayWindow
import de.jeanlucmakiola.floret.domain.Priority import de.jeanlucmakiola.agendula.domain.Priority
import de.jeanlucmakiola.floret.domain.SmartList import de.jeanlucmakiola.agendula.domain.SmartList
import de.jeanlucmakiola.floret.domain.Task import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.floret.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.floret.domain.TaskSection import de.jeanlucmakiola.agendula.domain.TaskSection
import de.jeanlucmakiola.floret.domain.TaskSections import de.jeanlucmakiola.agendula.domain.TaskSections
import de.jeanlucmakiola.floret.ui.common.ListNameChip import de.jeanlucmakiola.agendula.ui.common.priorityAccent
import de.jeanlucmakiola.floret.ui.common.Position import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.ui.common.PriorityChip import de.jeanlucmakiola.floret.time.formatDateTimeCompact
import de.jeanlucmakiola.floret.ui.common.formatDateTime import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.floret.ui.common.pastelize import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.ui.common.positionOf
import java.time.ZoneId import java.time.ZoneId
import kotlin.time.Clock import kotlin.time.Clock
@@ -115,7 +122,13 @@ fun TaskListScreen(
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
val listName = (state as? TaskListUiState.Content)?.listName val content = state as? TaskListUiState.Content
val listName = content?.listName
val listId = (filter as? TaskFilter.OfList)?.listId
// One add affordance, never two: a real list with the setting on gets a pinned
// bottom quick-add bar; everything else (incl. smart lists, which have no single
// target list) gets the floating "New task" button.
val showBottomAddBar = content?.bottomAddBar == true && listId != null
// Swipe-delete hides the row at once, then a floating "Deleted · Undo" chip // Swipe-delete hides the row at once, then a floating "Deleted · Undo" chip
// gives an undo window: Undo restores it, otherwise the delete commits. A // gives an undo window: Undo restores it, otherwise the delete commits. A
@@ -150,11 +163,18 @@ fun TaskListScreen(
) )
}, },
floatingActionButton = { floatingActionButton = {
if (!showBottomAddBar) {
ExtendedFloatingActionButton( ExtendedFloatingActionButton(
onClick = onNewTask, onClick = onNewTask,
icon = { Icon(Icons.Rounded.Add, contentDescription = null) }, icon = { Icon(Icons.Rounded.Add, contentDescription = null) },
text = { Text(stringResource(R.string.new_task)) }, text = { Text(stringResource(R.string.new_task)) },
) )
}
},
bottomBar = {
if (showBottomAddBar) {
listId?.let { id -> QuickAddBar(onAdd = { title -> viewModel.quickAdd(title, id) }) }
}
}, },
) { inner -> ) { inner ->
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
@@ -163,18 +183,26 @@ fun TaskListScreen(
TaskListUiState.Failure -> CenteredMessage(stringResource(R.string.task_list_failure), inner) TaskListUiState.Failure -> CenteredMessage(stringResource(R.string.task_list_failure), inner)
is TaskListUiState.Content -> TaskListBody(s, filter, inner, onOpenTask, onRequestDelete, viewModel) is TaskListUiState.Content -> TaskListBody(s, filter, inner, onOpenTask, onRequestDelete, viewModel)
} }
// A FAB-height band anchored at the bottom-start with the FAB's own
// margin; centring the chip in it lines the chip up beside the bottom-end
// FAB at exactly its height, rather than sitting a touch above it.
Box(
modifier = Modifier
.align(Alignment.BottomStart)
.padding(start = 16.dp, bottom = inner.calculateBottomPadding() + 16.dp)
.height(56.dp),
contentAlignment = Alignment.CenterStart,
) {
UndoChip( UndoChip(
visible = undoTarget != null, visible = undoTarget != null,
onUndo = { onUndo = {
undoTarget?.let { viewModel.undoDelete(it.taskId) } undoTarget?.let { viewModel.undoDelete(it.taskId) }
undoTarget = null undoTarget = null
}, },
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = inner.calculateBottomPadding() + 24.dp),
) )
} }
} }
}
} }
/** /**
@@ -251,20 +279,34 @@ private fun TaskListBody(
val childrenByParent = remember(state.tasks) { val childrenByParent = remember(state.tasks) {
state.tasks.filter { it.isSubtask }.groupBy { it.parentId!! } state.tasks.filter { it.isSubtask }.groupBy { it.parentId!! }
} }
// In a smart list a parent's subtasks are usually filtered out (they're undated
// or due other days), so we pull them on demand. Report only expanded parents
// whose full child set isn't already in this view; the VM then observes those
// children live. In a real list that set is empty, so no extra queries run.
val tasksById = remember(state.tasks) { state.tasks.associateBy { it.taskId } }
val parentsNeedingChildren = expandedParents.filter { id ->
val parent = tasksById[id]
parent != null && (childrenByParent[id]?.size ?: 0) != parent.subtaskTotal
}.toSet()
LaunchedEffect(parentsNeedingChildren) { viewModel.setLazyChildParents(parentsNeedingChildren) }
val lazyChildren by viewModel.lazyChildren.collectAsStateWithLifecycle()
// A parent's full child set: the rows already in this view when we hold them
// all, otherwise the lazily-loaded set fetched for smart lists.
fun childrenOf(task: Task): List<Task> {
val inList = childrenByParent[task.taskId].orEmpty()
return if (inList.size == task.subtaskTotal) inList else lazyChildren[task.taskId].orEmpty()
}
// When the bottom quick-add bar is shown it already lives in the scaffold's
// bottomBar (so `inner` covers it); otherwise reserve room to clear the FAB.
val bottomBarShown = state.bottomAddBar && listId != null
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues( contentPadding = PaddingValues(
top = inner.calculateTopPadding() + 8.dp, top = inner.calculateTopPadding() + 8.dp,
bottom = inner.calculateBottomPadding() + 96.dp, bottom = inner.calculateBottomPadding() + if (bottomBarShown) 16.dp else 96.dp,
), ),
) { ) {
if (listId != null) {
item(key = "inline-add") {
InlineAdd(onAdd = { title -> viewModel.quickAdd(title, listId) })
}
}
if (state.tasks.isEmpty()) { if (state.tasks.isEmpty()) {
item(key = "empty") { EmptyState(stringResource(R.string.task_list_empty)) } item(key = "empty") { EmptyState(stringResource(R.string.task_list_empty)) }
} }
@@ -295,38 +337,51 @@ private fun TaskListBody(
val rows = buildList { val rows = buildList {
val topLevel = section.tasks val topLevel = section.tasks
topLevel.forEachIndexed { p, task -> topLevel.forEachIndexed { p, task ->
val children = childrenByParent[task.taskId].orEmpty() // The full child set — already in this view for a real list,
// Only expandable when we hold every child: true in a real // or fetched on demand for a smart list (childrenOf). A parent
// list, but a smart list may omit children due on other // is expandable whenever it has children; until a smart list's
// days, and a half-shown set would mislead. // set has loaded the group simply shows nothing extra.
val canExpand = task.subtaskTotal > 0 && children.size == task.subtaskTotal val children = childrenOf(task)
val canExpand = task.subtaskTotal > 0
val isExpanded = canExpand && task.taskId in expandedParents val isExpanded = canExpand && task.taskId in expandedParents
val hasExpansionContent = children.isNotEmpty() || state.showAddSubtaskRow
// Top-level tasks keep their own connected run — full top // Top-level tasks keep their own connected run — full top
// only on the first, full bottom only on the last — so the // only on the first, full bottom only on the last — so the
// task after an expanded one stays mid-run (small top). An // task after an expanded one stays mid-run (small top). An
// expanded parent's bottom opens (small) to meet its subtasks. // expanded parent's bottom opens (small) to meet its subtasks.
val parentPosition = cornerPosition( val parentPosition = cornerPosition(
topFull = p == 0, topFull = p == 0,
bottomFull = if (isExpanded) false else p == topLevel.lastIndex, bottomFull = if (isExpanded && hasExpansionContent) false else p == topLevel.lastIndex,
) )
add(RenderRow(ListRow.Parent(task, canExpand, isExpanded), parentPosition)) add(RenderRow(ListRow.Parent(task, canExpand, isExpanded), parentPosition))
if (isExpanded) { if (isExpanded) {
// Subtasks connect up to the parent (small top) and to // When the add row is hidden (M5 setting), the last subtask
// each other; the inline add row below closes the group, // instead rounds the group off (full bottom).
// so every subtask is mid-run (small top + bottom). val sorted = children.sortedBy { it.isCompleted }
children.sortedBy { it.isCompleted }.forEach { sub -> sorted.forEachIndexed { c, sub ->
add(RenderRow(ListRow.Sub(sub), cornerPosition(topFull = false, bottomFull = false))) // Subtasks connect up to the parent (small top); with the
// add row present every subtask is mid-run, otherwise the
// last one closes the group (full bottom).
val closesGroup = !state.showAddSubtaskRow && c == sorted.lastIndex
add(RenderRow(ListRow.Sub(sub), cornerPosition(topFull = false, bottomFull = closesGroup)))
} }
// The add row rounds the group off (full bottom), // The add row rounds the group off (full bottom),
// independent of whatever top-level task follows. // independent of whatever top-level task follows.
if (state.showAddSubtaskRow) {
add(RenderRow(ListRow.AddSub(task), cornerPosition(topFull = false, bottomFull = true))) add(RenderRow(ListRow.AddSub(task), cornerPosition(topFull = false, bottomFull = true)))
} }
} }
} }
}
itemsIndexed(rows, key = { _, r -> r.row.key }) { index, r -> itemsIndexed(rows, key = { _, r -> r.row.key }) { index, r ->
val lastInRun = index == rows.lastIndex val lastInRun = index == rows.lastIndex
// animateItem so an (un)expanding group fades its subtasks in/out
// and slides the rows below — clean whether the children were
// already loaded (real list) or arrive a frame later (smart list).
val rowModifier = Modifier.animateItem()
when (val row = r.row) { when (val row = r.row) {
is ListRow.Parent -> TaskRow( is ListRow.Parent -> TaskRow(
modifier = rowModifier,
task = row.task, task = row.task,
position = r.position, position = r.position,
lastInRun = lastInRun, lastInRun = lastInRun,
@@ -350,6 +405,7 @@ private fun TaskListBody(
onClick = { onOpenTask(row.task.taskId) }, onClick = { onOpenTask(row.task.taskId) },
) )
is ListRow.Sub -> SubtaskRow( is ListRow.Sub -> SubtaskRow(
modifier = rowModifier,
task = row.task, task = row.task,
position = r.position, position = r.position,
lastInRun = lastInRun, lastInRun = lastInRun,
@@ -357,6 +413,7 @@ private fun TaskListBody(
onClick = { onOpenTask(row.task.taskId) }, onClick = { onOpenTask(row.task.taskId) },
) )
is ListRow.AddSub -> AddSubtaskRow( is ListRow.AddSub -> AddSubtaskRow(
modifier = rowModifier,
position = r.position, position = r.position,
lastInRun = lastInRun, lastInRun = lastInRun,
onAdd = { viewModel.quickAddSubtask(row.parent, it) }, onAdd = { viewModel.quickAddSubtask(row.parent, it) },
@@ -369,9 +426,9 @@ private fun TaskListBody(
} }
/** Inline "add a task" — title only, into the current list. */ /** Inline "add a task" — title only, into the current list. */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable @Composable
private fun InlineAdd(onAdd: (String) -> Unit) { private fun InlineAdd(onAdd: (String) -> Unit, elevated: Boolean = false) {
var text by remember { mutableStateOf("") } var text by remember { mutableStateOf("") }
fun submit() { fun submit() {
if (text.isNotBlank()) { if (text.isNotBlank()) {
@@ -379,10 +436,25 @@ private fun InlineAdd(onAdd: (String) -> Unit) {
text = "" text = ""
} }
} }
// Compose keeps a TextField focused (cursor blinking) even after the keyboard
// is dismissed by a tap outside it — so when the IME hides, drop focus too.
val focusManager = LocalFocusManager.current
val imeVisible = WindowInsets.isImeVisible
LaunchedEffect(imeVisible) {
if (!imeVisible) focusManager.clearFocus()
}
Surface( Surface(
shape = RoundedCornerShape(22.dp), shape = RoundedCornerShape(22.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh, // Floating use (bottom quick-add) steps up to the brightest container tone
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp), // — a real step above the surfaceContainerHigh task cards — and lifts off
// the list with a heavier shadow, so it doesn't blend into the rows.
color = if (elevated) {
MaterialTheme.colorScheme.surfaceContainerHighest
} else {
MaterialTheme.colorScheme.surfaceContainerHigh
},
shadowElevation = if (elevated) 8.dp else 0.dp,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
) { ) {
TextField( TextField(
value = text, value = text,
@@ -410,6 +482,23 @@ private fun InlineAdd(onAdd: (String) -> Unit) {
} }
} }
/**
* The bottom quick-add field: [InlineAdd] pinned as the scaffold's bottomBar, but
* with no bar plate of its own the rounded field floats on the screen background
* (its own shadow lifting it off the list). Rides above the keyboard and clears the
* navigation bar (ime navigation-bar insets).
*/
@Composable
private fun QuickAddBar(onAdd: (String) -> Unit) {
Box(
modifier = Modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets.ime.union(WindowInsets.navigationBars)),
) {
InlineAdd(onAdd = onAdd, elevated = true)
}
}
/** /**
* One task row: a tonal grouped-card surface (corners from [position], morphing * One task row: a tonal grouped-card surface (corners from [position], morphing
* rounder on press the app's shape family) wrapped in a swipe box. Swiping * rounder on press the app's shape family) wrapped in a swipe box. Swiping
@@ -429,6 +518,7 @@ private fun TaskRow(
onToggle: () -> Unit, onToggle: () -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier,
) { ) {
val dismissState = rememberSwipeToDismissBoxState( val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = { value -> confirmValueChange = { value ->
@@ -449,7 +539,7 @@ private fun TaskRow(
SwipeToDismissBox( SwipeToDismissBox(
state = dismissState, state = dismissState,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap), modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap),
// dismissDirection (not targetValue) tracks the live drag, so the colour + // dismissDirection (not targetValue) tracks the live drag, so the colour +
// icon reveal as you swipe rather than only once the threshold is crossed. // icon reveal as you swipe rather than only once the threshold is crossed.
backgroundContent = { SwipeBackground(dismissState.dismissDirection, task.isCompleted) }, backgroundContent = { SwipeBackground(dismissState.dismissDirection, task.isCompleted) },
@@ -476,10 +566,11 @@ private fun TaskRowContent(
val full by animateDpAsState(if (pressed) 36.dp else 22.dp, label = "fullCorner") 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 small by animateDpAsState(if (pressed) 36.dp else 6.dp, label = "smallCorner")
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
val due = task.due?.formatDateTime(task.isAllDay) val due = task.due?.formatDateTimeCompact(task.isAllDay)
val listName = task.listName?.takeIf { showListName && it.isNotBlank() } val listName = task.listName?.takeIf { showListName && it.isNotBlank() }
val hasSupporting = due != null || listName != null || subtaskTotal > 0 || // Everything secondary collapses into one quiet supporting line below the title.
task.priority != Priority.NONE val hasMeta = task.priority != Priority.NONE || due != null || subtaskTotal > 0 ||
listName != null
Surface( Surface(
onClick = onClick, onClick = onClick,
@@ -520,30 +611,18 @@ private fun TaskRowContent(
MaterialTheme.colorScheme.onSurface MaterialTheme.colorScheme.onSurface
}, },
) )
if (hasSupporting) { if (hasMeta) {
Spacer(Modifier.height(3.dp)) Spacer(Modifier.height(3.dp))
// Flow so the chips wrap to a further line when due date + list TaskMetaLine(
// + subtask progress can't share one (e.g. a dated task with priority = task.priority,
// subtasks), rather than squeezing the trailing chip. due = due,
FlowRow( subtaskDone = subtaskDone,
verticalArrangement = Arrangement.spacedBy(3.dp), subtaskTotal = subtaskTotal,
horizontalArrangement = Arrangement.spacedBy(8.dp), listName = listName,
itemVerticalAlignment = Alignment.CenterVertically, listColor = task.effectiveColor,
) { dark = dark,
if (task.priority != Priority.NONE) {
PriorityChip(task.priority, priorityLabel(task.priority))
}
if (due != null) {
Text(
text = due,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
if (listName != null) ListNameChip(listName, task.effectiveColor)
if (subtaskTotal > 0) SubtaskCountChip(subtaskDone, subtaskTotal)
}
}
} }
// Trailing area is only ever the expand control, so the row's right // Trailing area is only ever the expand control, so the row's right
// edge stays put whether or not the task has children (priority now // edge stays put whether or not the task has children (priority now
@@ -555,31 +634,85 @@ private fun TaskRowContent(
} }
} }
/** A small neutral pill showing subtask progress, e.g. "3 / 5". */ /**
* The single low-emphasis supporting line under a task title: priority, due date,
* subtask progress and (in mixed views) the list name, joined by middots. The text
* is quiet `onSurfaceVariant` throughout; the only colour is the priority flag and
* the list name (tinted to its list) so even a fully-populated row stays calm and
* one line instead of a stack of filled pills.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
private fun SubtaskCountChip(done: Int, total: Int) { private fun TaskMetaLine(
Surface( priority: Priority,
color = MaterialTheme.colorScheme.surfaceContainerHighest, due: String?,
shape = RoundedCornerShape(50), subtaskDone: Int,
) { subtaskTotal: Int,
listName: String?,
listColor: Int,
dark: Boolean,
) {
val muted = MaterialTheme.colorScheme.onSurfaceVariant
val style = MaterialTheme.typography.bodySmall
val segments = buildList<@Composable () -> Unit> {
if (priority != Priority.NONE) {
add {
Row( Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(3.dp),
) { ) {
Icon( Icon(
imageVector = Icons.AutoMirrored.Rounded.ListAlt, Icons.Rounded.Flag,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = priorityAccent(priority, dark),
modifier = Modifier.size(14.dp), modifier = Modifier.size(13.dp),
) )
Text(priorityLabel(priority), style = style, color = muted)
}
}
}
if (due != null) add { Text(due, style = style, color = muted) }
if (subtaskTotal > 0) {
add {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(3.dp),
) {
Icon(
Icons.AutoMirrored.Rounded.ListAlt,
contentDescription = null,
tint = muted,
modifier = Modifier.size(13.dp),
)
Text("$subtaskDone / $subtaskTotal", style = style, color = muted)
}
}
}
if (listName != null) {
add {
Text( Text(
text = "$done / $total", listName,
style = MaterialTheme.typography.labelSmall, style = style,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = pastelize(listColor, dark),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
) )
} }
} }
}
FlowRow(
verticalArrangement = Arrangement.spacedBy(2.dp),
itemVerticalAlignment = Alignment.CenterVertically,
) {
segments.forEachIndexed { i, segment ->
// A middot between segments — its own flow item, so the line can still
// wrap gracefully when truly everything is set on a narrow screen.
if (i > 0) {
Text("·", style = style, color = muted, modifier = Modifier.padding(horizontal = 6.dp))
}
segment()
}
}
} }
/** /**
@@ -593,6 +726,7 @@ private fun AddSubtaskRow(
position: Position, position: Position,
lastInRun: Boolean, lastInRun: Boolean,
onAdd: (String) -> Unit, onAdd: (String) -> Unit,
modifier: Modifier = Modifier,
) { ) {
var text by remember { mutableStateOf("") } var text by remember { mutableStateOf("") }
fun submit() { fun submit() {
@@ -606,7 +740,7 @@ private fun AddSubtaskRow(
shape = cardShape(position, 22.dp, 6.dp), shape = cardShape(position, 22.dp, 6.dp),
// Same tone as the subtask rows it closes, so the group reads as one unit. // Same tone as the subtask rows it closes, so the group reads as one unit.
color = MaterialTheme.colorScheme.surfaceContainer, color = MaterialTheme.colorScheme.surfaceContainer,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap), modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap),
) { ) {
Row( Row(
modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp).padding(start = 12.dp, end = 8.dp), modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp).padding(start = 12.dp, end = 8.dp),
@@ -719,6 +853,7 @@ private fun SubtaskRow(
lastInRun: Boolean, lastInRun: Boolean,
onToggle: () -> Unit, onToggle: () -> Unit,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier,
) { ) {
val interaction = remember { MutableInteractionSource() } val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState() val pressed by interaction.collectIsPressedAsState()
@@ -732,7 +867,7 @@ private fun SubtaskRow(
// reads as a distinct, recessive tone — the nesting cue alongside shape. // reads as a distinct, recessive tone — the nesting cue alongside shape.
color = MaterialTheme.colorScheme.surfaceContainer, color = MaterialTheme.colorScheme.surfaceContainer,
interactionSource = interaction, interactionSource = interaction,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap), modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap),
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier

View File

@@ -1,12 +1,13 @@
package de.jeanlucmakiola.floret.ui.tasklist package de.jeanlucmakiola.agendula.ui.tasklist
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.floret.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.floret.domain.Task import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.floret.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.floret.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskForm
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@@ -15,6 +16,7 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
@@ -30,7 +32,14 @@ sealed interface TaskListUiState {
* [TaskFilter.OfList] (for the top-bar title), `null` for smart lists * [TaskFilter.OfList] (for the top-bar title), `null` for smart lists
* the screen falls back to the smart label in that case. * the screen falls back to the smart label in that case.
*/ */
data class Content(val tasks: List<Task>, val listName: String? = null) : TaskListUiState 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
} }
/** /**
@@ -42,6 +51,7 @@ sealed interface TaskListUiState {
@HiltViewModel @HiltViewModel
class TaskListViewModel @Inject constructor( class TaskListViewModel @Inject constructor(
private val repository: TasksRepository, private val repository: TasksRepository,
private val settingsPrefs: SettingsPrefs,
) : ViewModel() { ) : ViewModel() {
private val filter = MutableStateFlow<TaskFilter?>(null) private val filter = MutableStateFlow<TaskFilter?>(null)
@@ -62,10 +72,16 @@ class TaskListViewModel @Inject constructor(
tasks.map { TaskListUiState.Content(it) } tasks.map { TaskListUiState.Content(it) }
} }
// Drop rows pending an undoable delete so the row vanishes on swipe // Drop rows pending an undoable delete so the row vanishes on swipe
// while the actual provider delete waits for the snackbar to commit. // while the actual provider delete waits for the snackbar to commit,
combine(content, pendingDeletes) { st, pending -> // 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) { if (st is TaskListUiState.Content) {
st.copy(tasks = st.tasks.filter { it.taskId !in pending }) st.copy(
tasks = st.tasks.filter { it.taskId !in pending },
showAddSubtaskRow = showRow,
bottomAddBar = bottomBar,
)
} else { } else {
st st
} }
@@ -75,6 +91,32 @@ class TaskListViewModel @Inject constructor(
} }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TaskListUiState.Loading) .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 bind(taskFilter: TaskFilter) { filter.value = taskFilter }
fun toggleComplete(task: Task) = viewModelScope.launch { fun toggleComplete(task: Task) = viewModelScope.launch {

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,67 +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 androidx.datastore.preferences.core.stringSetPreferencesKey
import de.jeanlucmakiola.floret.domain.TaskFormField
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,
/** Optional edit-form fields shown by default; the rest sit behind "More fields". */
val defaultEditFields: Set<TaskFormField> = emptySet(),
)
/** 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,
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 }
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 DEFAULT_EDIT_FIELDS = stringSetPreferencesKey("default_edit_fields")
}
}

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,28 +0,0 @@
package de.jeanlucmakiola.floret.ui.common
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import kotlin.time.Instant
/**
* Locale-aware display formatting for the [kotlin.time.Instant]s the domain
* uses. Kept tiny and dependency-free; the data layer stores instants, the UI
* renders them in the device's zone and locale.
*/
private val dateFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
private val timeFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
private fun Instant.atSystemZone() =
java.time.Instant.ofEpochMilli(toEpochMilliseconds())
.atZone(ZoneId.systemDefault())
/** Medium localized date, e.g. "18 Jun 2026". */
fun Instant.formatDate(): String = atSystemZone().format(dateFormatter)
/** Short localized time, e.g. "14:30". */
fun Instant.formatTime(): String = atSystemZone().format(timeFormatter)
/** Date alone for all-day items, otherwise date + time. */
fun Instant.formatDateTime(allDay: Boolean): String =
if (allDay) formatDate() else "${formatDate()} · ${formatTime()}"

View File

@@ -1,130 +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.graphics.Shape
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
}
/** Corner radii for a grouped segment: full at the group's outer edges, small between. */
fun groupedShape(position: Position, full: Dp, small: Dp): 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)
}
/**
* One segment of a grouped list: a tonal [Surface] whose corner radii come from
* [position] (so a run reads as a single rounded card), with a small gap below
* all but the last. Corners morph rounder on press — the expressive shape play.
* The caller supplies [content] and any horizontal inset via [modifier], so the
* same primitive serves both the 16dp-inset lists and full-width detail cards.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GroupedSurface(
position: Position,
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
color: Color = MaterialTheme.colorScheme.surfaceContainerHigh,
content: @Composable () -> Unit,
) {
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 = groupedShape(position, full, small)
val gap = when (position) {
Position.Top, Position.Middle -> Modifier.padding(bottom = 2.dp)
Position.Bottom, Position.Alone -> Modifier
}
val base = modifier.fillMaxWidth().then(gap)
if (onClick != null) {
Surface(onClick = onClick, color = color, shape = shape, interactionSource = interaction, modifier = base) { content() }
} else {
Surface(color = color, shape = shape, modifier = base) { content() }
}
}
/**
* One row in a grouped list: an M3 [ListItem] over a [GroupedSurface] (the
* tonal, position-shaped, press-morphing family container). Insets 16dp like
* the lists overview.
*/
@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 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 containerColor = if (selected) {
MaterialTheme.colorScheme.secondaryContainer
} else {
MaterialTheme.colorScheme.surfaceContainerHigh
}
GroupedSurface(
position = position,
modifier = modifier.padding(horizontal = 16.dp),
onClick = onClick,
color = containerColor,
) {
ListItem(
headlineContent = { Text(title) },
supportingContent = summary?.let { text -> { Text(text) } },
leadingContent = leading,
trailingContent = trailing,
colors = itemColors,
modifier = Modifier.heightIn(min = minHeight),
)
}
}

View File

@@ -1,83 +0,0 @@
package de.jeanlucmakiola.floret.ui.common
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
/**
* The app's borderless text input: no underline, no outline, just the tonal
* card behind it. Floret uses this inside a tonal [androidx.compose.material3.Surface]
* for the edit form (title + cards), so anything that takes text reads as one
* family rather than a boxed Material field. Ported from Calendula so the two
* apps share one input style.
*/
@Composable
fun InlineTextField(
value: String,
onValueChange: (String) -> Unit,
placeholder: String,
modifier: Modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
textStyle: TextStyle = MaterialTheme.typography.titleMedium,
singleLine: Boolean = true,
minLines: Int = 1,
keyboardType: KeyboardType = KeyboardType.Text,
capitalization: KeyboardCapitalization = KeyboardCapitalization.Sentences,
imeAction: ImeAction = ImeAction.Default,
/** Invoked when the IME action key (e.g. Done) is pressed. */
onImeAction: (() -> Unit)? = null,
) {
val resolvedStyle = textStyle.copy(
color = if (textStyle.color.isSpecified) {
textStyle.color
} else {
MaterialTheme.colorScheme.onSurface
},
)
BasicTextField(
value = value,
onValueChange = onValueChange,
textStyle = resolvedStyle,
singleLine = singleLine,
minLines = minLines,
keyboardOptions = KeyboardOptions(
keyboardType = keyboardType,
capitalization = capitalization,
imeAction = imeAction,
),
keyboardActions = onImeAction?.let { action ->
KeyboardActions(onAny = { action() })
} ?: KeyboardActions.Default,
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
decorationBox = { innerTextField ->
Box {
if (value.isEmpty()) {
// Clearly fainter than typed text, so a hint never reads as
// prefilled content.
Text(
text = placeholder,
style = resolvedStyle,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
)
}
innerTextField()
}
},
modifier = modifier,
)
}

View File

@@ -1,95 +0,0 @@
package de.jeanlucmakiola.floret.ui.common
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.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
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.vector.ImageVector
import androidx.compose.ui.unit.dp
/**
* The app's standard pick in a selection dialog: a full-width tonal card,
* optionally with a leading icon and a supporting line; the selected option is
* highlighted. Stack with 8dp gaps inside an AlertDialog — the only sanctioned
* selection-modal style (no radio rows, no bare text lists). Ported from
* Calendula so the families' dialogs read identically.
*/
@Composable
fun OptionCard(
label: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
icon: ImageVector? = null,
/** Icon tint override, e.g. a list colour; unspecified follows selection. */
iconTint: Color = Color.Unspecified,
supportingText: String? = null,
selected: Boolean = false,
/** Label colour override, e.g. primary for an emphasised entry. */
labelColor: Color = Color.Unspecified,
) {
val contentColor = if (selected) {
MaterialTheme.colorScheme.onSecondaryContainer
} else {
MaterialTheme.colorScheme.onSurface
}
Surface(
onClick = onClick,
color = if (selected) {
MaterialTheme.colorScheme.secondaryContainer
} else {
MaterialTheme.colorScheme.surfaceContainerHighest
},
shape = RoundedCornerShape(12.dp),
modifier = modifier.fillMaxWidth(),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
) {
if (icon != null) {
Icon(
imageVector = icon,
contentDescription = null,
tint = when {
iconTint.isSpecified -> iconTint
selected -> MaterialTheme.colorScheme.onSecondaryContainer
else -> MaterialTheme.colorScheme.onSurfaceVariant
},
modifier = Modifier.size(20.dp),
)
Spacer(Modifier.width(12.dp))
}
Column {
Text(
text = label,
style = MaterialTheme.typography.titleMedium,
color = if (labelColor.isSpecified) labelColor else contentColor,
)
if (supportingText != null) {
Text(
text = supportingText,
style = MaterialTheme.typography.bodySmall,
color = if (selected) {
MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.8f)
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
}
}
}

View File

@@ -1,21 +0,0 @@
package de.jeanlucmakiola.floret.ui.navigation
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.IntOffset
/**
* The horizontal slide spec for screen-to-screen navigation: the *fast*
* spring-physics spec from the active motion scheme — snappy with a subtle
* springy settle, rather than a fixed easing curve. Same choice as Calendula's
* calendar slide, so the two apps move the same way.
*
* Read in composable scope so the non-composable `NavHost` transition lambdas
* can capture it.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun rememberNavSlideSpec(): FiniteAnimationSpec<IntOffset> =
MaterialTheme.motionScheme.fastSpatialSpec()

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"?> <?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 A simple rounded check mark inside the 108dp adaptive-icon canvas
(72dp safe zone). Deliberately not Calendula's calendar mark, so the two (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. when the design lands — see docs/PLAN.md §9.
--> -->
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"

View File

@@ -1,5 +1,5 @@
<resources> <resources>
<string name="app_name">Floret</string> <string name="app_name">Agendula</string>
<string name="app_tagline">A modern Material 3 Expressive task app.\nComing into bloom.</string> <string name="app_tagline">A modern Material 3 Expressive task app.\nComing into bloom.</string>
<!-- Generic --> <!-- Generic -->
@@ -98,6 +98,20 @@
<string name="reminder_1_hour">1 hour before</string> <string name="reminder_1_hour">1 hour before</string>
<string name="reminder_1_day">1 day 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 --> <!-- Form validation -->
<string name="error_blank_title">Enter a title.</string> <string name="error_blank_title">Enter a title.</string>
<string name="error_no_list">Choose a list.</string> <string name="error_no_list">Choose a list.</string>
@@ -122,10 +136,86 @@
<!-- Provider / permission onboarding --> <!-- Provider / permission onboarding -->
<string name="onboarding_no_provider_title">A tasks app is needed</string> <string name="onboarding_no_provider_title">A tasks app is needed</string>
<string name="onboarding_no_provider_body">Floret shows and edits the tasks stored by OpenTasks or tasks.org, synced by DAVx5. Install one of them to get started.</string> <string name="onboarding_no_provider_body">Agendula shows and edits the tasks stored by OpenTasks or tasks.org, synced by DAVx5. Install one of them to get started.</string>
<string name="onboarding_permission_title">Allow access to your tasks</string> <string name="onboarding_permission_title">Allow access to your tasks</string>
<string name="onboarding_permission_body">Floret needs permission to read and write your tasks. That\'s the only thing it ever asks for.</string> <string name="onboarding_permission_body">Agendula needs permission to read and write your tasks. That\'s the only thing it ever asks for.</string>
<string name="onboarding_permission_button">Grant task access</string> <string name="onboarding_permission_button">Grant task access</string>
<string name="onboarding_install_opentasks">Install OpenTasks</string> <string name="onboarding_install_opentasks">Install OpenTasks</string>
<string name="onboarding_install_tasksorg">Install tasks.org</string> <string name="onboarding_install_tasksorg">Install tasks.org</string>
<!-- Reminder onboarding (one-time, after the provider grant) -->
<string name="reminder_onboarding_title">Never miss what\'s due</string>
<string name="reminder_onboarding_body">Tasks apps don\'t send reminders themselves, so Agendula delivers them for you. Turn them on to get a notification when a task is due.</string>
<string name="reminder_onboarding_enable_button">Enable reminders</string>
<string name="reminder_onboarding_skip_button">Not now</string>
<string name="reminder_benefit_delivery_title">Agendula reminds you</string>
<string name="reminder_benefit_delivery_body">It schedules a notification for each task with a due date.</string>
<string name="reminder_benefit_timing_title">On your schedule</string>
<string name="reminder_benefit_timing_body">Choose how far ahead to be reminded in Settings.</string>
<string name="reminder_benefit_reversible_title">Change it anytime</string>
<string name="reminder_benefit_reversible_body">Turn reminders off whenever you like — it\'s just a switch.</string>
<!-- Settings -->
<string name="settings_title">Settings</string>
<string name="settings_section_appearance">Appearance</string>
<string name="settings_appearance_subtitle">Theme and colour</string>
<string name="settings_section_task_form">Task form</string>
<string name="settings_task_form_subtitle">Default fields, list and subtasks</string>
<string name="settings_reminders_subtitle">Notifications for due tasks</string>
<string name="settings_form_fields_hint">Fields shown by default on a new task. The rest sit behind \"More fields\".</string>
<string name="settings_dynamic_color_unavailable">Available on Android 12 and later</string>
<!-- About -->
<string name="settings_about_author">by Jean-Luc Makiola</string>
<string name="settings_about_source">Source</string>
<string name="settings_license">License</string>
<string name="settings_about_support">Support development</string>
<string name="settings_about_version">Version %1$s</string>
<string name="settings_about_logo_desc">Agendula app icon</string>
<string name="settings_language">App language</string>
<string name="settings_language_auto">System default</string>
<string name="settings_report_problem">Report a problem</string>
<string name="settings_report_problem_hint">Open the issue tracker</string>
<string name="about_source_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/agendula</string>
<string name="crash_report_issue_title">Crash report</string>
<string name="report_issue_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/agendula/issues/new</string>
<string name="about_license_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/agendula/src/branch/main/LICENSE</string>
<string name="about_support_url" translatable="false">https://ko-fi.com/jeanlucmakiola</string>
<string name="settings_theme">Theme</string>
<string name="settings_theme_system">Follow system</string>
<string name="settings_theme_light">Light</string>
<string name="settings_theme_dark">Dark</string>
<string name="settings_dynamic_color">Dynamic colour</string>
<string name="settings_dynamic_color_hint">Use colours from your wallpaper</string>
<string name="settings_section_reminders">Reminders</string>
<string name="settings_reminders">Due reminders</string>
<string name="settings_reminders_hint">Notify me when a task is due</string>
<string name="settings_default_reminder">When to remind</string>
<string name="settings_exact_alarms">Exact timing</string>
<string name="settings_exact_alarms_allowed">Reminders fire at the exact time</string>
<string name="settings_exact_alarms_blocked">Blocked — tap to allow exact reminders</string>
<string name="settings_section_tasks">Tasks</string>
<string name="settings_default_list">Default list</string>
<string name="settings_default_list_first">First available list</string>
<string name="settings_add_subtask_row">Show \"add a subtask\" row</string>
<string name="settings_add_subtask_row_hint">An add field at the end of an expanded task\'s subtasks</string>
<string name="settings_bottom_add_bar">Bottom quick-add bar</string>
<string name="settings_bottom_add_bar_hint">Add tasks from a bar pinned to the bottom of a list, instead of the floating button</string>
<!-- Reminder lead times (custom amounts) -->
<plurals name="reminder_minutes">
<item quantity="one">%1$d minute before</item>
<item quantity="other">%1$d minutes before</item>
</plurals>
<plurals name="reminder_hours">
<item quantity="one">%1$d hour before</item>
<item quantity="other">%1$d hours before</item>
</plurals>
<plurals name="reminder_days">
<item quantity="one">%1$d day before</item>
<item quantity="other">%1$d days before</item>
</plurals>
<plurals name="reminder_weeks">
<item quantity="one">%1$d week before</item>
<item quantity="other">%1$d weeks before</item>
</plurals>
</resources> </resources>

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
package de.jeanlucmakiola.floret.data.tasks package de.jeanlucmakiola.agendula.data.tasks
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Lists import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Tasks import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.floret.domain.Priority import de.jeanlucmakiola.agendula.domain.Priority
import de.jeanlucmakiola.floret.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskForm
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import kotlin.time.Instant import kotlin.time.Instant

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,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 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 `.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 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 ## 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 plus `en-US/` summary/description). Per-version changelogs are generated into the
repo's `metadata/.../en-US/changelogs/<versionCode>.txt` from `CHANGELOG.md` at repo's `metadata/.../en-US/changelogs/<versionCode>.txt` from `CHANGELOG.md` at
release time; metadata is uploaded alongside `repo/` so changelog history 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 Where the project is and where it's going. This is the **status** view; the
design rationale behind each milestone lives in [`PLAN.md`](PLAN.md), and how the design rationale behind each milestone lives in [`PLAN.md`](PLAN.md), and how the
@@ -12,11 +12,14 @@ Status legend: ✅ done · 🚧 in progress · ⬜ not started
The full non-visual stack ("backoffice") over the OpenTasks `TaskContract` The full non-visual stack ("backoffice") over the OpenTasks `TaskContract`
provider is **done and unit-tested**, and the Material 3 Expressive UI is built provider is **done and unit-tested**, and the Material 3 Expressive UI is built
through **M4**: lists → task list (swipe gestures, inline add, smart-list section through **M5**: lists → task list (swipe gestures, inline add, smart-list section
headers) → detail / edit with full CRUD, date-time pickers, priority, headers) → detail / edit with full CRUD, date-time pickers, priority,
percent-complete, conflict-safe saves, per-task reminders, and subtask percent-complete, conflict-safe saves, per-task reminders, and subtask
create + reparent. Remaining work is M5 (notification / exact-alarm onboarding, create + reparent — plus a one-time reminder onboarding step and a Settings
default reminder UI) and M6 (Settings screen, Glance widget, i18n, release). 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).
--- ---
@@ -47,7 +50,7 @@ The real Material 3 Expressive UI, built screen by screen against the M1
ViewModels. ViewModels.
- ✅ Provider/permission onboarding gate (`RootScreen`). - ✅ Provider/permission onboarding gate (`RootScreen`).
- ✅ Lists overview (`ListsScreen`) — smart lists + user lists grouped by account. - ✅ Lists overview (`ListsScreen`) — smart lists + user lists grouped by account.
- ✅ Navigation host wiring (`FloretNavHost` + `Dest` route table: lists → task - ✅ Navigation host wiring (`AgendulaNavHost` + `Dest` route table: lists → task
list → detail / edit, each binding its M1 ViewModel from route args). list → detail / edit, each binding its M1 ViewModel from route args).
- ✅ Task list screen — checkbox rows + toggle-complete, swipe-to-complete / - ✅ Task list screen — checkbox rows + toggle-complete, swipe-to-complete /
-delete (`SwipeToDismissBox`), inline add field (`InlineAdd``quickAdd`), -delete (`SwipeToDismissBox`), inline add field (`InlineAdd``quickAdd`),
@@ -85,7 +88,7 @@ ViewModels.
last segment while the next top-level task stays mid-run. Children are 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' 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" `surfaceContainerHigh`) and with no colour bar (checkbox stays aligned). An expanded group ends with an inline "add a subtask"
row (always on for now; an opt-out toggle lands with the M6 settings screen). 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 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 that omit off-day children stay collapsed). `TaskDetail.parent` carries the
parent for the detail card. parent for the detail card.
@@ -95,25 +98,32 @@ ViewModels.
promote it); switching list clears the now-invalid parent. Candidates stay promote it); switching list clears the now-invalid parent. Candidates stay
active + top-level to keep nesting one level deep, matching the detail screen. active + top-level to keep nesting one level deep, matching the detail screen.
### 🚧 M5 — Reminders onboarding & polish ### M5 — Reminders onboarding & polish
The engine exists (M1: `ReminderScheduler` + boot / provider-change re-sync, The engine exists (M1: `ReminderScheduler` + boot / provider-change re-sync,
`DueReminderReceiver`, `TaskNotifier`). `DueReminderReceiver`, `TaskNotifier`).
- ✅ Per-task reminder-offset UI (`ReminderPickerDialog``TaskForm` - ✅ Per-task reminder-offset UI (`ReminderPickerDialog``TaskForm`
`reminderMinutesBeforeDue`). `reminderMinutesBeforeDue`).
- `POST_NOTIFICATIONS` + exact-alarm onboarding flow — currently only graceful - `POST_NOTIFICATIONS` onboarding flow — a one-time `ReminderOnboardingScreen`
runtime checks (`TaskNotifier.canPost`, `canScheduleExactAlarms`), no (Calendula's shell + copy adapted for tasks) gated in `RootScreen` after the
user-facing gate. provider/permission grant; it requests the runtime permission (API 33+) and
- ⬜ Default reminder-offset settings UI — `SettingsPrefs`/`SettingsViewModel` records the choice (`reminderOnboardingDone`). Re-requestable from Settings.
back it, but no Composable exposes it. - ✅ Exact-alarm surface — a status row in Settings → Reminders, shown only on
- ⬜ End-to-end verification on device. Android 12 (where `SCHEDULE_EXACT_ALARM` is revocable), deep-linking to the
system grant screen; on 13+ the app holds `USE_EXACT_ALARM` (always granted).
- ✅ Default reminder-offset settings UI — exposed as "When to remind" in
Settings → Reminders (`reminderLeadMinutes`), behind a master `remindersEnabled`
switch that gates the entire engine (`ReminderScheduler` clears all alarms when
off; `DueReminderReceiver` suppresses any in-flight fire).
- ⬜ End-to-end verification on device (build + unit tests green; not yet run on
a device with a live provider).
### 🚧 M6 — Settings, widget, i18n, release ### 🚧 M6 — Settings, widget, i18n, release
- ✅ F-Droid metadata scaffolded (`fdroid-metadata/`). - ✅ F-Droid metadata scaffolded (`fdroid-metadata/`).
- Settings screen — `SettingsViewModel` exists but no `SettingsScreen` - Settings screen — landed early with M5 (`SettingsScreen` in the nav graph,
Composable and it is not in the nav graph (only used by `MainActivity` for reached by the gear on the lists overview). Covers theme, dynamic colour, due
app-level theming). Needs theme / dynamic-color / language, default list, reminders (toggle + default offset + exact-alarm status), default list, and the
default reminder, and the opt-out toggle for the task list's inline add-a-subtask-row opt-out. Still ⬜ a **language** entry (deferred until there
add-a-subtask row (on by default since M4). are translations to switch to).
- ⬜ Glance task-list widget — deps present in `build.gradle.kts`, zero impl. - ⬜ Glance task-list widget — deps present in `build.gradle.kts`, zero impl.
- ⬜ Translations — only `res/values/` (English); no `values-XX`. - ⬜ Translations — only `res/values/` (English); no `values-XX`.
- ⬜ Finalize F-Droid metadata, confirm CI release flow. - ⬜ Finalize F-Droid metadata, confirm CI release flow.
@@ -130,12 +140,12 @@ permissions; ship self-contained. UI / repository / domain untouched — see
These carry over from [`PLAN.md`](PLAN.md) §9; resolved ones are struck through. These carry over from [`PLAN.md`](PLAN.md) §9; resolved ones are struck through.
1. ~~**Name** — `Floret`~~ confirmed (appId `de.jeanlucmakiola.floret`). 1. ~~**Name** — `Agendula`~~ confirmed (appId `de.jeanlucmakiola.agendula`).
2. ~~**tasks.org provider authority**~~ verified on device: 2. ~~**tasks.org provider authority**~~ verified on device:
`org.tasks.opentasks` + `org.tasks.permission.*`. `org.tasks.opentasks` + `org.tasks.permission.*`.
3. **jtx Board** — support its richer contract later, or stay OpenTasks-only? 3. **jtx Board** — support its richer contract later, or stay OpenTasks-only?
(Not in the candidate list today.) (Not in the candidate list today.)
4. **Posture B authority choice** — bundling `org.dmfs.tasks` makes Floret a 4. **Posture B authority choice** — bundling `org.dmfs.tasks` makes Agendula a
*replacement* for OpenTasks (one authority owner per device). Intended, but a *replacement* for OpenTasks (one authority owner per device). Intended, but a
conscious choice. conscious choice.
5. **Recurring tasks** — read as occurrences today (`isRecurring` flag exists); 5. **Recurring tasks** — read as occurrences today (`isRecurring` flag exists);

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 existing tasks provider (OpenTasks / tasks.org), so any CalDAV tasks synced to
your device via DAVx5, SmoothSync or DecSync show up automatically, and changes 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. 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

@@ -11,9 +11,10 @@ pluginManagement {
gradlePluginPortal() 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 { dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
@@ -23,5 +24,6 @@ dependencyResolutionManagement {
} }
} }
rootProject.name = "Floret" rootProject.name = "Agendula"
include(":app") include(":app")
includeBuild("floret-kit")