18 Commits

Author SHA1 Message Date
78c5fd1fd4 release: floret-kit 0.1.0
Finalize the 0.1.0 CHANGELOG entry (first tagged release). Consumed from
source via git submodule; tagging gives apps a stable, traceable pin.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:58:54 +02:00
e1919cab83 components: FullScreenPicker takes an optional actions slot
Forwards a trailing app-bar `actions` lambda through to CollapsingScaffold,
so a full-screen picker can carry a commit/extra action — a custom-value
Add/OK, a Reset — instead of being select-and-dismiss only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 14:23:07 +02:00
55ad536513 identity: add Modifier.animateContentSizeMotion()
Eases a composable's own height/size changes on the family's M3 Expressive
spatial motion scheme (fastSpatialSpec) instead of jumping — e.g. a text
field that wraps to a new line, or a value swapped for a longer one. Snaps
under reduced motion, like the other identity motion helpers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 10:55:57 +02:00
cded44207c core-time: add LocalDate.isoWeekNumber() (ISO-8601 week-of-year)
Centralises the calendar-week-number computation — ISO-8601
week-of-week-based-year (Monday-start, week 1 holds the first Thursday) —
that Calendula previously inlined identically in its Week and Month grid
headers. Names the scheme choice (there are several incompatible "week
number" definitions) and covers the year-boundary rollover cases with a
unit test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 10:14:20 +02:00
5a576c4d28 components: FullScreenPicker uses the pinned bar
Completes the picker upstream: FullScreenPicker now passes largeTopBar=false
to CollapsingScaffold, so the family's selection pickers sit under a pinned
single-line bar instead of a tall collapsing header (the short list has nothing
to scroll past). Matches Calendula's scrollable-picker fix; applies family-wide.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 21:47:17 +02:00
2e47271904 docs: multi-value reminders + new components; drop stray README conflict marker
Record core-reminders (multi-value) and the new components in CHANGELOG,
README module table and the ROADMAP extracted list; clarify that only the
reminder *scheduler* stays app-local. Also removes a leftover '>>>>>>> origin/main'
merge-conflict marker committed in README by an earlier merge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 21:11:25 +02:00
89e3deb9f6 components: extract ReorderableColumn + DebugRibbon
Family-generic widgets upstreamed from Calendula: ReorderableColumn (a
dependency-free drag-to-reorder for the short grouped-card Settings lists; pairs
with GroupedRow(gapBelow=false)) and DebugRibbon (a stark un-themed DEBUG corner
ribbon).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 21:11:25 +02:00
53217f139e components: largeTopBar, gapBelow, enabled
CollapsingScaffold gains largeTopBar (false = pinned single-line bar, the
picker variant); GroupedSurface/GroupedRow gain gapBelow to suppress the 2dp
inter-row gap for a caller that owns spacing; InlineTextField gains enabled
(disabled dims to a locked value). All additive, defaults preserve behaviour.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 21:11:25 +02:00
bd874c2586 core-reminders: multi-value reminder overrides
ReminderOverride.Minutes now carries a List<Int> of lead times so a target
can hold several reminders at once; an app that offers a single reminder uses a
one-element list. Map helpers move to Map<Long, List<Int>> (empty list = explicit
None), and the codec gains a third (list) separator while single legacy values
round-trip byte-identically. Adds reminderOverrideForMinutes/normalizeReminders/
reminderLeadsFor. Upstreamed from Calendula's per-calendar multi-reminders.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 21:11:24 +02:00
566caf4305 Merge origin/main: component superset alongside core-time date util
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:07:10 +02:00
d9e4e877cc components: grow GroupedRow + CollapsingScaffold + pickers to the family superset
Promote the richer variants Calendula had kept app-local into the shared kit, as
backward-compatible additions (every new parameter defaults to the prior
behaviour, so Agendula's call sites are unchanged):

- GroupedRow: add `dimmed` (fade headline/summary to M3 disabled emphasis while
  the trailing control stays live — for present-but-off rows) and `container`
  (tonal-colour override, e.g. a category tint).
- CollapsingScaffold: add `actions` (trailing app-bar items), `snackbarHost`,
  and opt-in `predictiveBack` (wires the back-gesture preview to onBack for
  full-screen surfaces that want it).
- FullScreenPicker: thread opt-in `predictiveBack` to the scaffold, and apply
  SOFT_INPUT_ADJUST_NOTHING so a picker's own imePadding lifts the focused field
  instead of leaving a black gap above the keyboard.
- OptionPicker: forward `predictiveBack` and add an optional `header` slot above
  the option rows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 20:49:34 +02:00
aee26a28f3 core-time: add formatDateTimeCompact for dense list rows
Drops the year in the current year and drops the time when it carries no information (all-day items or a midnight time), so list rows show glanceable dates like "20 Jun" or "20 Jun · 14:30".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 20:47:25 +02:00
86f44805d1 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	README.md
2026-06-28 19:20:30 +02:00
09d3f9b934 components: add AboutCard + LanguagePickerRow settings recipes
AboutCard — logo slot + app name/author, a row of outlined link buttons and an
optional tonal highlight link (each opens its URL). LanguagePickerRow — a
GroupedRow that opens the full-screen language picker, backed by core-locale's
AppLanguage. Both parameterized so each app keeps its own strings, logo and row
icon. Adds the components -> core-locale dependency.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:24:17 +02:00
24ada183e2 components: add OptionalFormSection (revealable form section)
The shared wrapper for optional edit-form fields behind a "More fields" toggle:
AnimatedVisibility with the family's expand+fade motion over a full-width Column.
Adds the first intra-kit dependency (components -> identity) for the shared
transitions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:17:57 +02:00
1e36c229ca components: add DialogControls (DialogAmountField + DialogUnitDropdown)
The tonal 3-digit amount input and unit dropdown shared by the custom
reminder/recurrence steps — the family's InlineTextField over a tonal surface,
matching the card/grouped-row language. Drawn from Calendula.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:15:08 +02:00
ea96e970c7 identity: add the shared content transitions
expandEnter/collapseExit (revealable sections), itemEnter (resolved content),
animateItemMotion (lazy-list relayout) and fadeThrough — all driven by the M3
Expressive motion scheme and honouring rememberReduceMotion. Drawn from
Calendula's motion helpers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:13:06 +02:00
25ec248e73 docs: changelog, docs/ set, CONTRIBUTING, LICENSE
Bring floret-kit up to the family's repo conventions:
- CHANGELOG.md (Keep a Changelog) — what each module added.
- README.md — full module table (core-time/core-crash/identity/components) +
  consumption notes and a docs index.
- docs/README.md (index), docs/ARCHITECTURE.md (module layering, the submodule +
  composite-build consumption model, JVM vs android-library, the reproducibility
  rules, the share-mechanics-not-look contract), docs/ROADMAP.md (extracted vs
  deferred vs deliberately-not-shared, from the cross-app survey).
- CONTRIBUTING.md — the no-app-coupling rules, how to add a module, how an app
  adopts the kit.
- LICENSE (MIT, matching the apps).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 13:42:53 +02:00
28 changed files with 1406 additions and 101 deletions

81
CHANGELOG.md Normal file
View File

@@ -0,0 +1,81 @@
# Changelog
All notable changes to floret-kit are documented here. The format follows
[Keep a Changelog](https://keepachangelog.com/).
floret-kit is consumed **from source** — each app embeds it as a git submodule
and builds it through a Gradle composite build, pinning a specific commit. There
are no binary releases; "version" tracks the shared `version` in the root build
(currently `0.1.0`). Entries are grouped by module.
## [Unreleased]
## [0.1.0] - 2026-07-07
### Added
- **`core-time`** (JVM library) — pure-Kotlin date/time helpers, no Android and
no third-party dependencies:
- `DayWindow.today()` — local-day boundaries for smart-list logic.
- `Instant.formatDate()` / `formatTime()` / `formatDateTime()` — locale- and
zone-aware display formatting over `kotlin.time.Instant`.
- `TimeBridge``Long` epoch-millis ↔ `Instant`, for content-provider I/O.
- `LocalDate.isoWeekNumber()` — ISO-8601 week-of-week-based-year, the
calendar-week number for week/month grids (one scheme, shared).
- **`core-crash`** (Android library) — privacy-respecting, on-device crash
capture and reporting. Writes an allowlist-only report (app/Android/device
version, locale, time, stack trace — nothing else) to private storage and
chains to the platform handler; uploads nothing. Includes startup crash-loop
detection, a report dialog that shows the full text before it leaves the
device, and an issue-tracker hand-off. Per-app specifics (display name, issue
URLs) come from a `CrashConfig` supplied at `install()`; each app keeps its
own thin themed `CrashReportActivity`.
- **`identity`** (Android library) — the family's Material 3 Expressive theme
factory `FloretExpressiveTheme(lightScheme, darkScheme, …)` (dynamic colour on
API 31+, system light/dark fallback, the standard motion scheme) plus the
shared motion helpers — `rememberNavSlideSpec()`, the content transitions,
`Modifier.predictiveBack()` and `Modifier.animateContentSizeMotion()` (eases a
block's own height changes, e.g. a text field wrapping to a new line), each
reduced-motion-aware. Each app passes its own seed-derived colour schemes
and typography — the mechanics are shared, the look is not.
- **`components`** (Android library) — the shared Compose component vocabulary:
- `GroupedSurface` / `GroupedRow` + `Position` / `positionOf` — the canonical
press-morphing tonal grouped-row primitive (the Android-15 settings pattern).
- `InlineTextField` — the borderless tonal text input (capitalization is a
parameter).
- `OptionCard` — the sanctioned selection-dialog pick.
- `CollapsingScaffold` — the collapsing settings/sub-screen scaffold; set
`largeTopBar = false` for a pinned single-line bar (the picker variant).
- `FullScreenPicker` / `OptionPicker` — the full-screen single-select picker,
now over a pinned single-line bar (a picker is a short list, so no tall
collapsing header to scroll past). `FullScreenPicker` takes an optional
`actions` slot (app-bar trailing items) for pickers that carry a commit or
extra action — a custom-value **Add**/**OK**, a **Reset**.
- `ReorderableColumn` — dependency-free drag-to-reorder for the short, fixed
grouped-card Settings lists; pairs with `GroupedRow(gapBelow = false)`, which
hands the column uniform control of row spacing.
- `DebugRibbon` — a stark, un-themed "DEBUG" corner ribbon; gate on
`BuildConfig.DEBUG` at the call site.
- `InlineTextField` gained `enabled` (a disabled field dims to a locked value).
- `pastelize()` — softens a raw provider colour to a theme-fitting pastel.
App-agnostic by design: apps compose their own screens and keep their own
identity chips/icons. String resources here (e.g. `back`) are fallbacks an app
can override.
- **`core-reminders`** (JVM library) — the app-agnostic reminder-override model:
`ReminderOverride` (`Inherit` / `None` / `Minutes`), where `Minutes` carries a
**list** of lead-times so a target can hold several reminders at once (an app
that offers a single reminder just uses a one-element list). Map helpers
(`reminderOverrideFor`, `reminderLeadsFor`, `applyReminderOverride`,
`reminderOverrideForMinutes`, `normalizeReminders`) plus `ReminderOverrideCodec`
(three configurable separators; single legacy values round-trip byte-identically)
and the lead-time unit model (`ReminderUnit`, `decomposeReminderMinutes`). The
labels/presets and the alarm scheduler stay per-app.
### Conventions
- **No `foojay` toolchain resolver** anywhere — it can fetch a JDK at build time,
which reproducible / official F-Droid builds reject. Modules set `jvmTarget`
directly. See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md).
- AGP 9.x provides built-in Kotlin compilation, so Android modules apply only the
Android library + Compose plugins (no separate `kotlin.android`).

77
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,77 @@
# Contributing to floret-kit
floret-kit is the shared Material 3 Expressive design system + plumbing for the
Floret app family. Before changing it, skim
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) (how it's built and consumed) and
[`docs/ROADMAP.md`](docs/ROADMAP.md) (what's in scope). This file is the
practical how.
## The rules
1. **Share mechanics, not look.** Extract the machinery; leave seed colours,
palettes, identity chips, and wording to the apps. If a piece can't be made
app-agnostic without contortion, it doesn't belong here yet.
2. **No app coupling.** A kit module never imports an app package, an app's `R`,
or a domain type. App-specific values arrive as **constructor/config
parameters** or **callbacks** (see `core-crash`'s `CrashConfig`,
`InlineTextField`'s `capitalization`).
3. **String resources are fallbacks.** Kit strings are English defaults an app
overrides via resource merge. Don't put app-specific or per-app-divergent
wording in the kit.
4. **Bake divergence into parameters, don't pick a silent winner.** Where two
apps differ, expose the difference. When a shared primitive adopts one app's
version as canonical, that's a deliberate, documented convergence.
5. **No `foojay` toolchain resolver** — not in any `*.gradle.kts`, not even as a
token in a comment. It breaks reproducible / official F-Droid builds. Set
`jvmTarget` directly. See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) §5.
## Prerequisites
- JDK 17
- Android SDK (compileSdk 37) for the Android modules — set `ANDROID_HOME` or add
a gitignored `local.properties` with `sdk.dir`. Pure-JVM modules need neither.
## Build & test
```sh
./gradlew :core-time:test # JVM module (JUnit 5 + Truth)
./gradlew :core-crash:testDebugUnitTest
./gradlew :identity:assembleDebug
./gradlew build # everything
```
In practice the kit is exercised through a consuming app's composite build (e.g.
`agendula`'s `./gradlew assembleDebug`), which compiles the included modules from
source.
## Adding a module
1. Create `:<module>` and add `include(":<module>")` to
[`settings.gradle.kts`](settings.gradle.kts).
2. Add a `<module>/build.gradle.kts`:
- **JVM:** `plugins { alias(libs.plugins.kotlin.jvm) }`, a `kotlin { compilerOptions { jvmTarget = JVM_17 } }` block, JUnit 5 test deps.
- **Android:** `plugins { alias(libs.plugins.android.library); alias(libs.plugins.kotlin.compose) }` (no `kotlin.android` — AGP 9.x is built-in), `namespace = "de.jeanlucmakiola.floret.<area>"`, `compileSdk 37` / `minSdk 29`, `jvmTarget = JVM_17`.
3. Put sources under `src/main/kotlin/de/jeanlucmakiola/floret/<area>/`. The root
build's `subprojects {}` sets the shared `group`/`version`, so no per-module
coordinates are needed.
4. Add an entry to [`CHANGELOG.md`](CHANGELOG.md), the README module table, and
(if status-worthy) [`docs/ROADMAP.md`](docs/ROADMAP.md).
## Adopting the kit in an app
1. `git submodule add <kit-url> floret-kit`.
2. `includeBuild("floret-kit")` in the app's `settings.gradle.kts`.
3. `implementation("de.jeanlucmakiola.floret:<module>")` in the app module.
4. Ensure CI checks out submodules (`submodules: recursive`) and the included
build can find the SDK (`ANDROID_HOME`). For official F-Droid reproducible
builds, add `submodules: true` to the fdroiddata recipe and have the repro
guard scan the submodule.
5. The app pins a kit commit via the submodule; bump it deliberately to adopt kit
changes.
## Validate before pushing
- `python3 -c "import yaml; yaml.safe_load(open('<workflow>.yml'))"` after any
workflow edit — indentation bites.
- A consuming app's `lintDebug` + `testDebugUnitTest` + `assembleDebug` should be
green after a kit change you mean to land.

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Jean-Luc Makiola
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -25,20 +25,37 @@ implementation("de.jeanlucmakiola.floret:core-time")
```
Gradle's dependency substitution maps `de.jeanlucmakiola.floret:<module>` to the
local source module — edit the kit and the app picks it up immediately.
local source module — edit the kit and the app picks it up immediately. Android
modules need an SDK location for the included build: set `ANDROID_HOME` (CI) or
add a gitignored `<app>/floret-kit/local.properties` with `sdk.dir` (locally).
See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the full consumption model.
## Modules
| Module | What it holds |
|--------|---------------|
| `core-time` | Pure-Kotlin date/time helpers: local-day windows (`DayWindow`) for smart-list logic and locale/zone-aware display formatting (`Instant.formatDate()` / `formatTime()` / `formatDateTime()`). No Android, no deps. |
| `core-reminders` | Pure-Kotlin reminder-lead plumbing: the lead-time unit model (`ReminderUnit`, `decomposeReminderMinutes`), the per-target override model (`ReminderOverride` + `reminderLeadFor` / `applyReminderOverride`) and the stored-format codec (`ReminderOverrideCodec`, separators configurable per app). No Android, no deps. Each app layers its own DataStore + string labels on top. |
| `core-locale` | Per-app language plumbing (`AppLanguage`): read the shipped languages from the app's `res/xml/locales_config.xml`, get/set the applied language via `AppCompatDelegate`, and render each language's autonym. The app passes its own `locales_config` resource id; appcompat only, no Compose. |
| Module | Type | What it holds |
|--------|------|---------------|
| `core-time` | JVM | Date/time helpers: `DayWindow` local-day windows, locale/zone-aware `Instant` formatting, `TimeBridge` (millis ↔ `Instant`). No Android, no deps. |
| `core-reminders` | JVM | Reminder-lead plumbing: the lead-time unit model (`ReminderUnit`, `decomposeReminderMinutes`), the per-target override model (`ReminderOverride` + `reminderLeadFor` / `applyReminderOverride`) and the stored-format codec (`ReminderOverrideCodec`, separators configurable per app). Each app layers its own DataStore + string labels on top. |
| `core-locale` | Android | Per-app language plumbing (`AppLanguage`): read the shipped languages from the app's `res/xml/locales_config.xml`, get/set the applied language via `AppCompatDelegate`, render each language's autonym. The app passes its own `locales_config`; appcompat only, no Compose. |
| `core-crash` | Android | Privacy-respecting on-device crash capture + report dialog + issue-tracker hand-off. Parameterized per app via `CrashConfig`. |
| `identity` | Android | The M3 Expressive theme factory `FloretExpressiveTheme(…)`, `rememberNavSlideSpec()`, the content transitions (`expandEnter`/`collapseExit`/`itemEnter`/…) and the `predictiveBack` peek. Each app supplies its own seed/palette. |
| `components` | Android | Shared Compose vocabulary + recipes: `GroupedSurface`/`GroupedRow`, `InlineTextField`, `OptionCard`, `CollapsingScaffold`, `OptionPicker`, `ReorderableColumn`, `DebugRibbon`, `pastelize()`, `DialogControls`, `OnboardingScaffold`, `OptionalFormSection`, `AboutCard`, `LanguagePickerRow`. |
_More modules (identity/theme, components, screen recipes, provider/prefs/crash plumbing) land as they're extracted from the apps._
_The principle: the **mechanics** are shared, the **look** stays per-app. See
[`docs/ROADMAP.md`](docs/ROADMAP.md) for what's extracted, deferred, and
deliberately not shared._
## Docs
See [`docs/`](docs/) — start with [`docs/README.md`](docs/README.md)
([ARCHITECTURE](docs/ARCHITECTURE.md), [ROADMAP](docs/ROADMAP.md)) and
[`CONTRIBUTING.md`](CONTRIBUTING.md) to add or change a module.
[`CHANGELOG.md`](CHANGELOG.md) tracks what landed.
## Build
```sh
./gradlew :core-time:test
./gradlew :core-time:test # JVM module tests
./gradlew :core-crash:testDebugUnitTest
./gradlew build # everything (Android modules need an SDK; see above)
```

View File

@@ -33,6 +33,12 @@ kotlin {
}
dependencies {
// identity supplies the shared motion (expandEnter/collapseExit) that the
// revealable components animate with.
implementation(project(":identity"))
// core-locale backs the language-picker recipe (AppLanguage).
implementation(project(":core-locale"))
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.foundation)

View File

@@ -3,6 +3,7 @@ package de.jeanlucmakiola.floret.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
@@ -18,6 +19,7 @@ import androidx.compose.material3.LargeTopAppBar
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
@@ -25,13 +27,25 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.floret.identity.predictiveBack
/**
* The family's collapsing scaffold for settings hubs, sub-screens and the
* full-screen pickers: a [LargeTopAppBar] that collapses on scroll, a back
* button, and a vertically scrolling content column. [content] lays out the
* connected grouped rows. The back content description uses the kit's `back`
* string, which an app can override with its own (localized) `back`.
* full-screen pickers. By default a [LargeTopAppBar] whose title shrinks into
* the bar as the content scrolls — for settings and sub-screens, where the large
* header sets the page — over a vertically scrolling content column. [content]
* lays out the connected grouped rows. The back content description uses the
* kit's `back` string, which an app can override with its own (localized) `back`.
*
* Set [largeTopBar] to false for a pinned, single-line [TopAppBar] instead: the
* title sits in the bar from the start with no expanded header to scroll past.
* Preferred for selection pickers, where the tall header is only empty space
* above a short list.
*
* Optional extras, all defaulting to the bare scaffold so existing callers are
* unchanged: [actions] adds trailing app-bar items; [snackbarHost] hosts
* transient messages; [predictiveBack] (off by default) wires the gesture
* preview to [onBack] for full-screen surfaces that want it.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -39,32 +53,55 @@ fun CollapsingScaffold(
title: String,
onBack: () -> Unit,
modifier: Modifier = Modifier,
largeTopBar: Boolean = true,
predictiveBack: Boolean = false,
actions: @Composable RowScope.() -> Unit = {},
snackbarHost: @Composable () -> Unit = {},
content: @Composable ColumnScope.() -> Unit,
) {
val scrollBehavior =
val scrollBehavior = if (largeTopBar) {
TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
} else {
TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
}
Scaffold(
modifier = modifier
.then(if (predictiveBack) Modifier.predictiveBack(onBack = onBack) else Modifier)
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface)
.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
LargeTopAppBar(
title = { Text(title) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = stringResource(R.string.back),
)
}
},
scrollBehavior = scrollBehavior,
colors = TopAppBarDefaults.largeTopAppBarColors(
scrolledContainerColor = MaterialTheme.colorScheme.surface,
),
)
val navigationIcon = @Composable {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = stringResource(R.string.back),
)
}
}
if (largeTopBar) {
LargeTopAppBar(
title = { Text(title) },
navigationIcon = navigationIcon,
actions = actions,
scrollBehavior = scrollBehavior,
colors = TopAppBarDefaults.largeTopAppBarColors(
scrolledContainerColor = MaterialTheme.colorScheme.surface,
),
)
} else {
TopAppBar(
title = { Text(title) },
navigationIcon = navigationIcon,
actions = actions,
scrollBehavior = scrollBehavior,
colors = TopAppBarDefaults.topAppBarColors(
scrolledContainerColor = MaterialTheme.colorScheme.surface,
),
)
}
},
snackbarHost = snackbarHost,
) { innerPadding ->
Column(
modifier = Modifier

View File

@@ -0,0 +1,50 @@
package de.jeanlucmakiola.floret.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
/**
* A Flutter-style "DEBUG" corner ribbon, drawn across the top-right corner of
* the app. Deliberately a stark, un-themed marker (not a product component) so a
* debug build is unmistakable at a glance — gate it on `BuildConfig.DEBUG` at the
* call site so it never reaches a release build. Non-interactive: it's a plain
* label with no pointer handler, so taps fall through to whatever is beneath it.
*
* Drop it in as the last child of a full-screen [androidx.compose.foundation.layout.Box]
* so it overlays the UI.
*/
@Composable
fun BoxScope.DebugRibbon() {
Text(
text = "DEBUG",
color = Color.White,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.sp,
textAlign = TextAlign.Center,
modifier = Modifier
.align(Alignment.TopEnd)
.zIndex(1f)
// Push the band out so its midline crosses the very corner, then
// rotate it to the classic 45° ribbon.
.offset(x = 36.dp, y = 24.dp)
.rotate(45f)
.background(Color(0xFFB23B00))
.width(140.dp)
.padding(vertical = 2.dp),
)
}

View File

@@ -0,0 +1,95 @@
package de.jeanlucmakiola.floret.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
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.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
/**
* Tonal 3-digit number input shared by the custom reminder/recurrence steps and
* the pickers — the family's [InlineTextField] over a tonal surface, so it
* matches the card/grouped-row design language (not Material's outlined field).
* Non-digit input and anything past three digits is ignored.
*/
@Composable
fun DialogAmountField(
value: String,
onValueChange: (String) -> Unit,
placeholder: String,
) {
// surfaceContainerHighest — the picker/dialog sits on surfaceContainerHigh,
// so anything lower vanishes.
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHighest,
shape = RoundedCornerShape(12.dp),
) {
InlineTextField(
value = value,
onValueChange = { text ->
if (text.length <= 3 && text.all(Char::isDigit)) onValueChange(text)
},
placeholder = placeholder,
textStyle = MaterialTheme.typography.titleMedium,
keyboardType = KeyboardType.Number,
modifier = Modifier
.width(72.dp)
.padding(horizontal = 14.dp, vertical = 12.dp),
)
}
}
/** Tonal dropdown trigger + menu shared by the custom reminder/recurrence steps and pickers. */
@Composable
fun DialogUnitDropdown(
label: String,
entries: List<String>,
onPick: (Int) -> Unit,
) {
var open by remember { mutableStateOf(false) }
Box {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHighest,
shape = RoundedCornerShape(12.dp),
onClick = { open = true },
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 14.dp, end = 8.dp, top = 12.dp, bottom = 12.dp),
) {
Text(text = label, style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.width(4.dp))
Icon(imageVector = Icons.Default.ArrowDropDown, contentDescription = null)
}
}
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
entries.forEachIndexed { index, entry ->
DropdownMenuItem(
text = { Text(entry) },
onClick = {
onPick(index)
open = false
},
)
}
}
}
}

View File

@@ -0,0 +1,26 @@
package de.jeanlucmakiola.floret.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import de.jeanlucmakiola.floret.identity.collapseExit
import de.jeanlucmakiola.floret.identity.expandEnter
/**
* A form section that reveals/hides with the family's expand+fade motion — the
* shared wrapper for the optional fields behind a "More fields" toggle in the
* edit forms. [content] is laid out in a full-width [Column].
*/
@Composable
fun OptionalFormSection(visible: Boolean, content: @Composable ColumnScope.() -> Unit) {
AnimatedVisibility(
visible = visible,
enter = expandEnter(),
exit = collapseExit(),
) {
Column(modifier = Modifier.fillMaxWidth(), content = content)
}
}

View File

@@ -51,6 +51,10 @@ fun groupedShape(position: Position, full: Dp, small: Dp): Shape = when (positio
* 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.
*
* Set [gapBelow] to false to suppress that 2dp separation — for a caller that
* owns uniform spacing itself, e.g. [ReorderableColumn], where every slot must
* share the same pitch for the drag maths to line up.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -59,6 +63,7 @@ fun GroupedSurface(
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
color: Color = MaterialTheme.colorScheme.surfaceContainerHigh,
gapBelow: Boolean = true,
content: @Composable () -> Unit,
) {
val interaction = remember { MutableInteractionSource() }
@@ -66,9 +71,10 @@ fun GroupedSurface(
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 gap = when {
!gapBelow -> Modifier
position == Position.Top || position == Position.Middle -> Modifier.padding(bottom = 2.dp)
else -> Modifier
}
val base = modifier.fillMaxWidth().then(gap)
if (onClick != null) {
@@ -82,6 +88,13 @@ fun GroupedSurface(
* 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.
*
* [selected] highlights the row in the secondary container. [dimmed] fades the
* headline and summary to the M3 disabled emphasis while leaving the [trailing]
* control at full opacity — for rows that are present but switched off.
* [container] overrides the row's tonal colour (e.g. a category tint); ignored
* when [selected]. [gapBelow] (see [GroupedSurface]) suppresses the inter-row
* gap for a caller that owns its own uniform spacing, e.g. [ReorderableColumn].
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -91,7 +104,10 @@ fun GroupedRow(
modifier: Modifier = Modifier,
summary: String? = null,
selected: Boolean = false,
dimmed: Boolean = false,
container: Color? = null,
minHeight: Dp = 72.dp,
gapBelow: Boolean = true,
leading: @Composable (() -> Unit)? = null,
trailing: @Composable (() -> Unit)? = null,
onClick: (() -> Unit)? = null,
@@ -104,19 +120,30 @@ fun GroupedRow(
supportingColor = MaterialTheme.colorScheme.onSecondaryContainer,
trailingIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
)
} else if (dimmed) {
// M3 disabled emphasis (0.38α) on the text/leading; the trailing control
// stays full-opacity so a toggle reads as live even on a faded row.
val muted = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
ListItemDefaults.colors(
containerColor = Color.Transparent,
headlineColor = muted,
leadingIconColor = muted,
supportingColor = muted,
)
} else {
ListItemDefaults.colors(containerColor = Color.Transparent)
}
val containerColor = if (selected) {
MaterialTheme.colorScheme.secondaryContainer
} else {
MaterialTheme.colorScheme.surfaceContainerHigh
val containerColor = when {
selected -> MaterialTheme.colorScheme.secondaryContainer
container != null -> container
else -> MaterialTheme.colorScheme.surfaceContainerHigh
}
GroupedSurface(
position = position,
modifier = modifier.padding(horizontal = 16.dp),
onClick = onClick,
color = containerColor,
gapBelow = gapBelow,
) {
ListItem(
headlineContent = { Text(title) },

View File

@@ -36,6 +36,7 @@ fun InlineTextField(
textStyle: TextStyle = MaterialTheme.typography.titleMedium,
singleLine: Boolean = true,
minLines: Int = 1,
enabled: Boolean = true,
keyboardType: KeyboardType = KeyboardType.Text,
capitalization: KeyboardCapitalization = KeyboardCapitalization.Sentences,
imeAction: ImeAction = ImeAction.Default,
@@ -43,15 +44,17 @@ fun InlineTextField(
onImeAction: (() -> Unit)? = null,
) {
val resolvedStyle = textStyle.copy(
color = if (textStyle.color.isSpecified) {
textStyle.color
} else {
MaterialTheme.colorScheme.onSurface
color = when {
// A disabled field reads as dimmed, like a locked value.
!enabled -> MaterialTheme.colorScheme.onSurfaceVariant
textStyle.color.isSpecified -> textStyle.color
else -> MaterialTheme.colorScheme.onSurface
},
)
BasicTextField(
value = value,
onValueChange = onValueChange,
enabled = enabled,
textStyle = resolvedStyle,
singleLine = singleLine,
minLines = minLines,

View File

@@ -1,24 +1,32 @@
package de.jeanlucmakiola.floret.components
import android.view.WindowManager
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.RowScope
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.DialogWindowProvider
/**
* Full-screen scaffold for selection pickers: a full-bleed [Dialog] that reuses
* [CollapsingScaffold] (collapsing title + back), so a picker is visually
* identical to a settings sub-page and uses the full width. [content] places the
* connected grouped rows; selecting one calls [onDismiss].
* [CollapsingScaffold] with a pinned single-line bar, so a picker uses the full
* width without a tall header — it's a short selection list, so the large
* collapsing header would only be empty space to scroll past. [content] places
* the connected grouped rows; selecting one calls [onDismiss].
*/
@Composable
fun FullScreenPicker(
title: String,
onDismiss: () -> Unit,
predictiveBack: Boolean = false,
actions: @Composable RowScope.() -> Unit = {},
content: @Composable ColumnScope.() -> Unit,
) {
Dialog(
@@ -28,7 +36,23 @@ fun FullScreenPicker(
decorFitsSystemWindows = false,
),
) {
CollapsingScaffold(title = title, onBack = onDismiss, content = content)
// The dialog window pans by default when the keyboard opens, which —
// combined with the content's own imePadding — leaves a fixed black gap
// above the keyboard. Switch it to ADJUST_NOTHING so the window stays
// full-screen and imePadding alone lifts the focused field.
val view = LocalView.current
SideEffect {
(view.parent as? DialogWindowProvider)?.window
?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
}
CollapsingScaffold(
title = title,
onBack = onDismiss,
largeTopBar = false,
predictiveBack = predictiveBack,
actions = actions,
content = content,
)
}
}
@@ -46,8 +70,11 @@ fun <T> OptionPicker(
onSelect: (T) -> Unit,
onDismiss: () -> Unit,
leading: (@Composable (T) -> Unit)? = null,
header: (@Composable ColumnScope.() -> Unit)? = null,
predictiveBack: Boolean = false,
) {
FullScreenPicker(title = title, onDismiss = onDismiss) {
FullScreenPicker(title = title, onDismiss = onDismiss, predictiveBack = predictiveBack) {
header?.invoke(this)
options.forEachIndexed { index, option ->
val isSelected = option == selected
GroupedRow(

View File

@@ -0,0 +1,183 @@
package de.jeanlucmakiola.floret.components
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
/** Uniform row height for [ReorderableColumn]; a fixed pitch keeps drag maths exact. */
val ReorderableRowHeight: Dp = 64.dp
private val RowGap: Dp = 2.dp
/**
* A vertical list whose rows can be dragged into a new order by their handle.
*
* Built for the short, fixed grouped-card lists in Settings — no external
* dependency and no [androidx.compose.foundation.lazy.LazyColumn] (the settings
* screens are a single [androidx.compose.foundation.verticalScroll] column, which
* can't nest a scrolling list). Rows are a fixed [ReorderableRowHeight] with a
* uniform gap, so a row's target slot is simply how many whole pitches it has
* been dragged. The held row follows the finger while the others slide out of
* its way (animated); on release the order is committed immediately with a
* single [onReorder] call, then the held row eases into its new slot as a
* purely visual settle — safe to interrupt with another drag, since the
* commit itself never waits on it.
*
* [rowContent] receives the [Position] for the row's place in the order (to reuse
* [GroupedRow]'s card shaping — pass `gapBelow = false` there, this owns spacing)
* and a `dragHandle` [Modifier] to attach to the element that starts a drag.
*/
@Composable
fun <T> ReorderableColumn(
items: List<T>,
keyOf: (T) -> Any,
onReorder: (List<T>) -> Unit,
modifier: Modifier = Modifier,
rowContent: @Composable (item: T, position: Position, dragHandle: Modifier, isDragging: Boolean) -> Unit,
) {
val pitchPx = with(LocalDensity.current) { (ReorderableRowHeight + RowGap).toPx() }
val scope = rememberCoroutineScope()
// Local working copy; re-seeded when the incoming list changes (including the
// echo of our own committed order).
var order by remember(items) { mutableStateOf(items) }
var draggedKey by remember { mutableStateOf<Any?>(null) }
// Live translation of the held row from its slot (px); also drives the
// release settle — re-based onto the row's new slot once the order commits
// (see onDragEnd), then eased down to zero.
var dragOffset by remember { mutableFloatStateOf(0f) }
// The running release/cancel settle, cancelled if a new drag pre-empts it.
// Purely visual — the order commit (see onDragEnd) never depends on it.
var settleJob by remember { mutableStateOf<Job?>(null) }
val draggedIndex = draggedKey?.let { key -> order.indexOfFirst { keyOf(it) == key }.takeIf { it >= 0 } }
// Whole slots dragged → the slot the held row currently hovers over. Derived
// so recomposition only fires when the *target slot* actually changes, not on
// every dragged pixel: dragOffset moves every frame during a drag, but the
// held row's own translation is already applied in the draw phase via
// graphicsLayer below, so only the neighbours' shift (which depends on this)
// needs to recompose, and only when a slot boundary is actually crossed.
// (Read as a plain val, not `by`, below: a delegated property has a custom
// getter and Kotlin won't smart-cast it in the `when` over `targetIndex` in
// the loop, so the derived value is captured into a real local instead.)
val targetIndex = remember(items, pitchPx) {
derivedStateOf {
val idx = draggedKey?.let { key -> order.indexOfFirst { keyOf(it) == key }.takeIf { it >= 0 } }
idx?.let { (it + (dragOffset / pitchPx).roundToInt()).coerceIn(0, order.lastIndex) }
}
}.value
Column(modifier, verticalArrangement = Arrangement.spacedBy(RowGap)) {
order.forEachIndexed { index, item ->
val key = keyOf(item)
val isDragged = key == draggedKey
// Slide neighbours by one pitch to open the gap the held row will drop
// into. Snap (not animate) once idle, so committing the new order — which
// moves each row's slot — doesn't visibly fight a lingering animation.
val shift = when {
draggedIndex == null || targetIndex == null || isDragged -> 0f
index in (draggedIndex + 1)..targetIndex -> -pitchPx
index in targetIndex until draggedIndex -> pitchPx
else -> 0f
}
val animatedShift by animateFloatAsState(
targetValue = shift,
animationSpec = if (draggedKey != null) spring(stiffness = Spring.StiffnessMediumLow) else snap(),
label = "reorderShift",
)
val dragHandle = Modifier.pointerInput(key) {
detectDragGestures(
onDragStart = {
settleJob?.cancel()
draggedKey = key
dragOffset = 0f
},
onDrag = { change, amount ->
change.consume()
dragOffset += amount.y
},
onDragEnd = {
val from = order.indexOfFirst { keyOf(it) == key }
if (from < 0) return@detectDragGestures
val to = (from + (dragOffset / pitchPx).roundToInt()).coerceIn(0, order.lastIndex)
if (to != from) {
// Commit synchronously and unconditionally, before the settle
// animation below runs — the commit must not depend on that
// coroutine reaching its end, or a new drag starting within the
// ~160ms settle window would cancel it and silently revert an
// already-finished reorder.
order = order.toMutableList().apply { add(to, removeAt(from)) }
onReorder(order)
// The row now lays out at slot `to` instead of `from`; re-base
// the live offset onto that new slot (same visual position,
// expressed relative to the new one) so the settle below eases
// it the rest of the way instead of jumping.
dragOffset -= (to - from) * pitchPx
}
settleJob = scope.launch {
// Purely visual from here: ease the held row onto its slot, then
// release the drag state. Safe to cancel — the order was already
// committed above.
Animatable(dragOffset).animateTo(0f, tween(160)) { dragOffset = value }
draggedKey = null
dragOffset = 0f
}
},
onDragCancel = {
settleJob = scope.launch {
Animatable(dragOffset).animateTo(0f, tween(160)) { dragOffset = value }
draggedKey = null
dragOffset = 0f
}
},
)
}
Box(
Modifier
.height(ReorderableRowHeight)
.zIndex(if (isDragged) 1f else 0f)
.graphicsLayer {
translationY = if (isDragged) dragOffset else animatedShift
if (isDragged) {
scaleX = 1.02f
scaleY = 1.02f
shadowElevation = 8.dp.toPx()
shape = RoundedCornerShape(20.dp)
clip = false
}
},
) {
rowContent(item, positionOf(index, order.size), dragHandle, isDragged)
}
}
}
}

View File

@@ -0,0 +1,147 @@
package de.jeanlucmakiola.floret.components
import android.content.Intent
import android.net.Uri
import androidx.annotation.XmlRes
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.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
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.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.floret.locale.AppLanguage
/** A labelled, icon-led external link rendered as a button in [AboutCard]. */
data class AboutLink(val icon: ImageVector, val label: String, val url: String)
/**
* The "about this app" card shared across the family: the app's [logo] beside
* its [appName] and [author], then [primaryLinks] as a row of equal-width
* outlined buttons (e.g. source, licence) and an optional full-width
* [highlightLink] tonal button (e.g. donate). Each link opens its URL in the
* browser. The app supplies its own logo, strings and links.
*/
@Composable
fun AboutCard(
logo: @Composable () -> Unit,
appName: String,
author: String,
primaryLinks: List<AboutLink>,
highlightLink: AboutLink? = null,
) {
val context = LocalContext.current
val open = { url: String ->
runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) }
Unit
}
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = RoundedCornerShape(24.dp),
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.fillMaxWidth().padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
logo()
Spacer(Modifier.width(16.dp))
Column(Modifier.weight(1f)) {
Text(appName, style = MaterialTheme.typography.titleLarge)
Text(
text = author,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (primaryLinks.isNotEmpty()) {
Spacer(Modifier.height(12.dp))
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
primaryLinks.forEach { link ->
OutlinedButton(
onClick = { open(link.url) },
contentPadding = PaddingValues(horizontal = 12.dp),
modifier = Modifier.weight(1f),
) {
Icon(link.icon, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text(link.label)
}
}
}
}
if (highlightLink != null) {
Spacer(Modifier.height(8.dp))
FilledTonalButton(onClick = { open(highlightLink.url) }, modifier = Modifier.fillMaxWidth()) {
Icon(highlightLink.icon, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text(highlightLink.label)
}
}
}
}
}
/**
* A settings row that opens a full-screen language picker, backed by
* [AppLanguage]. The choice is mirrored locally so the row updates instantly
* before the activity recreation lands. [autoLabel] names the follow-the-system
* option (the `null` tag); [localesConfig] is the app's `res/xml/locales_config`;
* [leading] is the row's icon.
*/
@Composable
fun LanguagePickerRow(
position: Position,
title: String,
autoLabel: String,
@XmlRes localesConfig: Int,
leading: @Composable () -> Unit,
) {
val context = LocalContext.current
var current by remember { mutableStateOf(AppLanguage.currentTag()) }
var showDialog by remember { mutableStateOf(false) }
// null = follow the system; the rest are BCP-47 tags from locales_config.xml.
val options = remember { listOf<String?>(null) + AppLanguage.supportedTags(context, localesConfig) }
val label: (String?) -> String = { tag -> tag?.let { AppLanguage.displayName(it) } ?: autoLabel }
GroupedRow(
title = title,
summary = label(current),
position = position,
leading = leading,
onClick = { showDialog = true },
)
if (showDialog) {
OptionPicker(
title = title,
options = options,
selected = current,
label = { label(it) },
onSelect = {
current = it
AppLanguage.apply(it)
},
onDismiss = { showDialog = false },
)
}
}

View File

@@ -1,9 +1,12 @@
package de.jeanlucmakiola.floret.reminders
/**
* A target's override of the default reminder lead time — a list (Agendula) or a
* calendar (Calendula). Targets are keyed by id in the override map; this is the
* choice a picker returns.
* A target's override of the default reminder lead times — a list (Agendula) or
* a calendar (Calendula). A target can carry several lead times at once ("1 week
* before" *and* "on the day"), so the value is a set of minutes-before-due;
* an app that only offers a single reminder just uses a one-element list.
* Targets are keyed by id in the override map; this is the choice a picker
* returns.
*/
sealed interface ReminderOverride {
/** No override — the target uses the global default. */
@@ -12,33 +15,54 @@ sealed interface ReminderOverride {
/** Explicit "no reminder" for this target, regardless of the global default. */
data object None : ReminderOverride
/** A specific lead time in minutes before due. */
data class Minutes(val minutes: Int) : ReminderOverride
/** Specific lead times in minutes before due (non-empty, distinct, ascending). */
data class Minutes(val minutes: List<Int>) : ReminderOverride
}
/**
* The lead time for [id]: its override if one is set (a null map value means an
* explicit "no reminder"), otherwise the global [default]. A null result means
* no reminder. Mirrors the absent/null/value semantics of the override map.
*/
fun Map<Long, Int?>.reminderLeadFor(id: Long, default: Int): Int? =
if (containsKey(id)) this[id] else default
/** Distinct, ascending lead times — the canonical form stored and resolved. */
fun List<Int>.normalizeReminders(): List<Int> = distinct().sorted()
/**
* Read [id]'s entry as a [ReminderOverride]: absent → [Inherit], null → [None],
* a value → [Minutes].
* The lead times for [id]: its override if one is set (an empty-list value means
* an explicit "no reminder"), otherwise the global [default]. An empty result
* means no reminder. Mirrors the absent/empty/value semantics of the override
* map.
*/
fun Map<Long, Int?>.reminderOverrideFor(id: Long): ReminderOverride = when {
fun Map<Long, List<Int>>.reminderLeadsFor(id: Long, default: List<Int>): List<Int> =
if (containsKey(id)) getValue(id) else default
/**
* Read [id]'s entry as a [ReminderOverride]: absent → [Inherit], empty → [None],
* a non-empty value → [Minutes].
*/
fun Map<Long, List<Int>>.reminderOverrideFor(id: Long): ReminderOverride = when {
!containsKey(id) -> ReminderOverride.Inherit
this[id] == null -> ReminderOverride.None
else -> ReminderOverride.Minutes(getValue(id)!!)
getValue(id).isEmpty() -> ReminderOverride.None
else -> ReminderOverride.Minutes(getValue(id))
}
/** Apply [override] for [id] to this override map ([Inherit] removes the key). */
fun MutableMap<Long, Int?>.applyReminderOverride(id: Long, override: ReminderOverride) {
fun MutableMap<Long, List<Int>>.applyReminderOverride(id: Long, override: ReminderOverride) {
when (override) {
ReminderOverride.Inherit -> remove(id)
ReminderOverride.None -> put(id, null)
is ReminderOverride.Minutes -> put(id, override.minutes)
ReminderOverride.None -> put(id, emptyList())
is ReminderOverride.Minutes -> put(id, override.minutes.normalizeReminders())
}
}
/**
* The override a chosen lead-time set maps to when emitted from a picker. A
* non-empty set is [ReminderOverride.Minutes]; an empty set (the last time was
* cleared) reverts to [ReminderOverride.Inherit] on a per-target picker
* ([allowInherit]) — so an accidental clear can't silently wipe the target's
* default — and to explicit [ReminderOverride.None] otherwise (the global
* default, where empty legitimately means no reminder). Pure, for unit tests.
*/
fun reminderOverrideForMinutes(minutes: List<Int>, allowInherit: Boolean): ReminderOverride {
val norm = minutes.normalizeReminders()
return when {
norm.isNotEmpty() -> ReminderOverride.Minutes(norm)
allowInherit -> ReminderOverride.Inherit
else -> ReminderOverride.None
}
}

View File

@@ -1,35 +1,55 @@
package de.jeanlucmakiola.floret.reminders
/**
* Encodes a per-target reminder-override map (`id → minutes`, where a null value
* is an explicit "no reminder") to and from a single stored string, e.g.
* `"12:30;7:none"`.
* Encodes a per-target reminder-override map (`id → lead times`, where an empty
* list is an explicit "no reminder") to and from a single stored string, e.g.
* `"12:30,60;7:none"`.
*
* The separators are configurable because each app already has bytes on disk in
* its own dialect — Agendula stores `id:minutes`, Calendula `id=minutes` — and
* the stored format must stay stable. Use [DEFAULT] (Agendula's `:` / `;`) or
* build a codec with the app's own separators; never change an app's separators
* after release without a migration.
* Three separators, all configurable because each app already has bytes on disk
* in its own dialect and the stored format must stay stable:
* - [entrySep] between targets (`;`)
* - [keyValueSep] between a target id and its lead times (Agendula `:`,
* Calendula `=`)
* - [listSep] between lead times within one target (`,`)
*
* A single legacy value (`id:30`, no [listSep]) still parses to a one-element
* list, and a one-element list serialises back with no [listSep] — so an app
* that stored a single value per target before multi-reminders round-trips
* byte-identically. Only the exact [noneToken] means an explicit no-reminder
* override (empty list); a non-sentinel value that parses to no valid minutes is
* garbage and drops the entry, so the target inherits the global default rather
* than silently reading as "no reminder". Never change an app's separators after
* release without a migration.
*/
class ReminderOverrideCodec(
private val entrySep: String = ";",
private val keyValueSep: String = ":",
private val listSep: String = ",",
private val noneToken: String = "none",
) {
/** Decode the stored string into a map (a null value = explicit no-reminder). */
fun parse(stored: String?): Map<Long, Int?> {
/** Decode the stored string into a map (an empty-list value = explicit no-reminder). */
fun parse(stored: String?): Map<Long, List<Int>> {
if (stored.isNullOrBlank()) return emptyMap()
return stored.split(entrySep).mapNotNull { entry ->
val parts = entry.split(keyValueSep).takeIf { it.size == 2 } ?: return@mapNotNull null
val id = parts[0].toLongOrNull() ?: return@mapNotNull null
val value = if (parts[1] == noneToken) null else parts[1].toIntOrNull() ?: return@mapNotNull null
id to value
when (val value = parts[1]) {
noneToken -> id to emptyList()
else -> value.split(listSep).mapNotNull { it.trim().toIntOrNull() }
.normalizeReminders().takeIf { it.isNotEmpty() }?.let { id to it }
?: return@mapNotNull null
}
}.toMap()
}
/** Encode the map back to its stored string form. */
fun serialize(map: Map<Long, Int?>): String =
map.entries.joinToString(entrySep) { (id, minutes) -> "$id$keyValueSep${minutes ?: noneToken}" }
fun serialize(map: Map<Long, List<Int>>): String =
map.entries.joinToString(entrySep) { (id, minutes) ->
val value =
if (minutes.isEmpty()) noneToken
else minutes.normalizeReminders().joinToString(listSep) { it.toString() }
"$id$keyValueSep$value"
}
companion object {
/** Agendula's dialect: `id:minutes` entries joined by `;`. */

View File

@@ -21,32 +21,47 @@ class ReminderModelTest {
}
@Test
fun `reminderLeadFor distinguishes absent, explicit-none and a value`() {
val map = mapOf(1L to 30, 2L to null)
assertThat(map.reminderLeadFor(1L, default = 10)).isEqualTo(30)
assertThat(map.reminderLeadFor(2L, default = 10)).isNull() // explicit no-reminder
assertThat(map.reminderLeadFor(3L, default = 10)).isEqualTo(10) // inherits default
fun `normalize dedupes and sorts`() {
assertThat(listOf(60, 10, 60, 0).normalizeReminders()).containsExactly(0, 10, 60).inOrder()
}
@Test
fun `reminderOverrideFor maps absent, null and value to the choice types`() {
val map = mapOf(1L to 30, 2L to null)
assertThat(map.reminderOverrideFor(1L)).isEqualTo(ReminderOverride.Minutes(30))
fun `reminderLeadsFor distinguishes absent, explicit-none and a value`() {
val map = mapOf(1L to listOf(30, 60), 2L to emptyList())
assertThat(map.reminderLeadsFor(1L, default = listOf(10))).containsExactly(30, 60).inOrder()
assertThat(map.reminderLeadsFor(2L, default = listOf(10))).isEmpty() // explicit no-reminder
assertThat(map.reminderLeadsFor(3L, default = listOf(10))).containsExactly(10) // inherits default
}
@Test
fun `reminderOverrideFor maps absent, empty and value to the choice types`() {
val map = mapOf(1L to listOf(30, 60), 2L to emptyList())
assertThat(map.reminderOverrideFor(1L)).isEqualTo(ReminderOverride.Minutes(listOf(30, 60)))
assertThat(map.reminderOverrideFor(2L)).isEqualTo(ReminderOverride.None)
assertThat(map.reminderOverrideFor(3L)).isEqualTo(ReminderOverride.Inherit)
}
@Test
fun `applyReminderOverride writes, clears and removes entries`() {
val map = mutableMapOf<Long, Int?>()
map.applyReminderOverride(1L, ReminderOverride.Minutes(45))
assertThat(map).containsExactly(1L, 45)
fun `applyReminderOverride writes normalized, clears and removes entries`() {
val map = mutableMapOf<Long, List<Int>>()
map.applyReminderOverride(1L, ReminderOverride.Minutes(listOf(60, 45, 45)))
assertThat(map.getValue(1L)).containsExactly(45, 60).inOrder() // deduped + sorted
map.applyReminderOverride(1L, ReminderOverride.None)
assertThat(map[1L]).isNull()
assertThat(map.getValue(1L)).isEmpty()
assertThat(map).containsKey(1L)
map.applyReminderOverride(1L, ReminderOverride.Inherit)
assertThat(map).doesNotContainKey(1L)
}
@Test
fun `reminderOverrideForMinutes maps an empty set to inherit or none by allowInherit`() {
assertThat(reminderOverrideForMinutes(listOf(60, 10, 10), allowInherit = true))
.isEqualTo(ReminderOverride.Minutes(listOf(10, 60)))
assertThat(reminderOverrideForMinutes(emptyList(), allowInherit = true))
.isEqualTo(ReminderOverride.Inherit)
assertThat(reminderOverrideForMinutes(emptyList(), allowInherit = false))
.isEqualTo(ReminderOverride.None)
}
}

View File

@@ -8,8 +8,8 @@ class ReminderOverrideCodecTest {
private val codec = ReminderOverrideCodec.DEFAULT
@Test
fun `round-trips ids, minutes and explicit none`() {
val map = mapOf(12L to 30, 7L to null, 3L to 0)
fun `round-trips ids, multi-value minutes and explicit none`() {
val map = mapOf(12L to listOf(30, 60), 7L to emptyList(), 3L to listOf(0))
assertThat(codec.parse(codec.serialize(map))).isEqualTo(map)
}
@@ -26,24 +26,33 @@ class ReminderOverrideCodecTest {
}
@Test
fun `none token decodes to a null value`() {
assertThat(codec.parse("7:none")).isEqualTo(mapOf(7L to null))
fun `none token decodes to an empty-list value`() {
assertThat(codec.parse("7:none")).isEqualTo(mapOf(7L to emptyList<Int>()))
}
@Test
fun `garbage entries are dropped, valid ones kept`() {
assertThat(codec.parse("12:30;garbage;x:5;9:notnum;4:10"))
.isEqualTo(mapOf(12L to 30, 4L to 10))
fun `a legacy single value round-trips byte-identically`() {
// Pre-multi-reminders each target stored one value ("30"); it must still
// parse to a one-element list and serialise back with no list separator.
assertThat(codec.parse("12:30")).isEqualTo(mapOf(12L to listOf(30)))
assertThat(codec.serialize(mapOf(12L to listOf(30)))).isEqualTo("12:30")
}
@Test
fun `garbage entries are dropped, none is kept, valid ones kept`() {
// 9:notnum parses to no valid minutes → dropped (inherit), not read as none.
assertThat(codec.parse("12:30,60;garbage;x:5;9:notnum;7:none;4:10"))
.isEqualTo(mapOf(12L to listOf(30, 60), 7L to emptyList(), 4L to listOf(10)))
}
@Test
fun `a custom dialect keeps a separate on-disk format`() {
// Calendula's dialect: id=minutes entries.
val calendula = ReminderOverrideCodec(entrySep = ";", keyValueSep = "=", noneToken = "none")
val map = mapOf(12L to 30, 7L to null)
val calendula = ReminderOverrideCodec(entrySep = ";", keyValueSep = "=", listSep = ",", noneToken = "none")
val map = mapOf(12L to listOf(30, 60), 7L to emptyList())
val stored = calendula.serialize(map)
assertThat(stored).contains("12=30")
assertThat(stored).contains("12=30,60")
assertThat(stored).contains("7=none")
assertThat(calendula.parse(stored)).isEqualTo(map)
// The Agendula dialect cannot read Calendula's bytes — the formats are distinct.

View File

@@ -1,8 +1,13 @@
package de.jeanlucmakiola.floret.time
import java.time.LocalDate
import java.time.LocalTime
import java.time.ZoneId
import java.time.chrono.IsoChronology
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeFormatterBuilder
import java.time.format.FormatStyle
import java.util.Locale
import kotlin.time.Instant
/**
@@ -26,3 +31,28 @@ 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()}"
// The locale's medium date pattern with the year token stripped, e.g. "20 Jun" /
// "Jun 20" — derived from the pattern so day/month order still follows the locale.
// Built once; locale changes within a running process are rare enough to ignore.
private val dateNoYearFormatter: DateTimeFormatter = run {
val medium = DateTimeFormatterBuilder.getLocalizedDateTimePattern(
FormatStyle.MEDIUM, null, IsoChronology.INSTANCE, Locale.getDefault(),
)
val noYear = medium.replace(Regex("[\\s.,/'-]*y+[\\s.,/'-]*"), " ").trim()
DateTimeFormatter.ofPattern(noYear, Locale.getDefault())
}
/**
* Compact form for dense list rows: the year is dropped when the date is in the
* current year, and the time is dropped when it carries no information (all-day
* items, or a midnight time). Examples: "20 Jun", "20 Jun · 14:30", "20 Jun 2027".
*/
fun Instant.formatDateTimeCompact(allDay: Boolean): String {
val zdt = atSystemZone()
val datePart =
if (zdt.year == LocalDate.now(ZoneId.systemDefault()).year) zdt.format(dateNoYearFormatter)
else formatDate()
return if (allDay || zdt.toLocalTime() == LocalTime.MIDNIGHT) datePart
else "$datePart · ${formatTime()}"
}

View File

@@ -0,0 +1,16 @@
package de.jeanlucmakiola.floret.time
import java.time.LocalDate
import java.time.temporal.IsoFields
/**
* ISO-8601 week-of-week-based-year (1..53) for [this] date: weeks start on
* Monday and week 1 is the one containing the year's first Thursday. This is the
* calendar-week number shown in week/month grids.
*
* Centralised here so every surface agrees on one scheme — "week number" has
* several incompatible definitions (ISO vs. locale-first-day vs. US), and the
* ISO year a date belongs to can differ from its calendar year around New Year
* (e.g. 2018-12-31 is week 1 of 2019; 2021-01-01 is week 53 of 2020).
*/
fun LocalDate.isoWeekNumber(): Int = get(IsoFields.WEEK_OF_WEEK_BASED_YEAR)

View File

@@ -0,0 +1,37 @@
package de.jeanlucmakiola.floret.time
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import java.time.LocalDate
class IsoWeekTest {
@Test
fun `first Thursday's week is week 1`() {
// 2026-01-01 is a Thursday → ISO week 1.
assertThat(LocalDate.of(2026, 1, 1).isoWeekNumber()).isEqualTo(1)
}
@Test
fun `mid-year week`() {
assertThat(LocalDate.of(2026, 6, 15).isoWeekNumber()).isEqualTo(25)
}
@Test
fun `late-December days can roll into week 1 of the next ISO year`() {
// 2018-12-31 (Mon) belongs to ISO week 1 of 2019.
assertThat(LocalDate.of(2018, 12, 31).isoWeekNumber()).isEqualTo(1)
}
@Test
fun `early-January days can belong to the last week of the previous ISO year`() {
// 2021-01-01 (Fri) belongs to ISO week 53 of 2020.
assertThat(LocalDate.of(2021, 1, 1).isoWeekNumber()).isEqualTo(53)
}
@Test
fun `a 53-week year reports week 53`() {
// 2020-12-31 (Thu) is ISO week 53.
assertThat(LocalDate.of(2020, 12, 31).isoWeekNumber()).isEqualTo(53)
}
}

147
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,147 @@
# floret-kit — architecture
How the kit is structured, built, and consumed **as it stands today**. For what
is and isn't extracted, see [`ROADMAP.md`](ROADMAP.md); for the practical how of
adding a module, see [`../CONTRIBUTING.md`](../CONTRIBUTING.md).
---
## 1. The thesis in one sentence
floret-kit is a Gradle build of small, focused modules — plumbing and a Material
3 Expressive design layer — that the family's apps draw from instead of
re-deriving the same code. The whole design hangs off one rule:
> Share the **mechanics**, keep the **look** per-app.
Theme machinery, content-provider plumbing, and component primitives are shared;
seed colours, palettes, identity chips, domain types, and app-specific wording
stay in each app. The kit never knows about a specific app's domain.
---
## 2. How an app consumes the kit
Each app embeds this repo as a **git submodule** and wires it in as a Gradle
**composite build**. The kit is therefore always built **from source** — there
are no published binaries — which keeps every consuming app reproducible (a
requirement for official F-Droid).
```kotlin
// <app>/settings.gradle.kts
includeBuild("floret-kit")
```
```kotlin
// <app>/app/build.gradle.kts
implementation("de.jeanlucmakiola.floret:core-time")
implementation("de.jeanlucmakiola.floret:components")
```
Every module sets `group = "de.jeanlucmakiola.floret"` (via the root build's
`subprojects {}`), so Gradle's dependency substitution maps
`de.jeanlucmakiola.floret:<module>` to the local `project(":<module>")` of the
included build. Editing the kit is picked up by the app's next build with no
publish step.
Each app **pins a specific kit commit** through its submodule gitlink. So a
breaking change in the kit can't reach an app until that app deliberately
re-pins — apps stay on independent cadences (important while Calendula ships and
Agendula moves fast).
### SDK location for the included build
An included Android build needs to locate the Android SDK independently of the
host app. Provide it via:
- **CI:** an `ANDROID_HOME` env var (the apps set it at the job level), or
- **locally:** a gitignored `<app>/floret-kit/local.properties` with `sdk.dir`.
Pure-JVM modules (`core-time`) need neither.
### Submodules in CI / F-Droid
Consuming apps must check out submodules (`submodules: recursive` in the
checkout step) so `floret-kit/` isn't empty. For an app published via **official
F-Droid reproducible builds** (Calendula), the fdroiddata recipe additionally
needs `submodules: true` on the build entry, and the app's reproducibility guard
should scan the submodule's Gradle scripts (see §5).
---
## 3. Module layering
| Layer | Modules | Notes |
|---|---|---|
| Plumbing | `core-time` (JVM) | Pure logic; no Android, no deps. |
| Plumbing | `core-crash` (Android) | Self-contained subsystem; parameterized per app. |
| Design — identity | `identity` (Android) | Theme factory + motion. App supplies seed/palette. |
| Design — components | `components` (Android) | Shared Compose primitives. App composes screens. |
Dependencies between modules are kept minimal: `core-time` depends on nothing in
the kit; `identity` and `components` depend only on Compose + (for components)
`material-icons-core`. There is no app-level or cross-domain coupling.
Each module's package is `de.jeanlucmakiola.floret.<area>` (e.g.
`de.jeanlucmakiola.floret.components`).
---
## 4. Module types & the build
Two kinds of module:
- **JVM library** (`core-time`) — `org.jetbrains.kotlin.jvm`, `jvmTarget = 17`,
JUnit 5 + Truth tests. The right home for pure logic; cheapest to build and
test.
- **Android library** (`core-crash`, `identity`, `components`) —
`com.android.library` + the Compose plugin, `compileSdk 37` / `minSdk 29`.
> **AGP 9.x provides built-in Kotlin compilation.** Android modules therefore
> apply only `com.android.library` and `org.jetbrains.kotlin.plugin.compose` —
> **not** a separate `org.jetbrains.kotlin.android` plugin (applying it errors).
> They set `jvmTarget` directly in a `kotlin { compilerOptions { … } }` block.
Versions live in [`gradle/libs.versions.toml`](../gradle/libs.versions.toml),
kept in lockstep with the apps' catalogs.
---
## 5. Reproducibility rules
Because consuming apps build the kit from source — and at least one publishes via
official F-Droid reproducible builds — the kit follows the same invariants:
1. **No `foojay` toolchain resolver**, anywhere (not even as a token in a
comment). It can fetch a JDK over the network at build time, which the offline
F-Droid source scanner rejects. Modules set `jvmTarget` directly instead of
using a Java toolchain block that would need a resolver.
2. **No published artifacts.** Built from source, pinned by commit.
3. Android library modules emit no APK-level metadata of their own (no
`buildConfig`); the consuming app controls APK-level reproducibility
(`vcsInfo`/`dependenciesInfo`).
A consuming app's reproducibility guard should scan the submodule's `*.gradle.kts`
for the resolver too, since the included build is part of the from-source graph.
---
## 6. The design-divergence contract
The design modules (`identity`, `components`) carry the family's look, so they
extract **mechanics** and leave **identity** to the app:
- `identity` exposes a theme **factory**; each app passes its own seed-derived
`lightScheme`/`darkScheme` and typography. Seed colours and hand-tuned
fallbacks are never shared constants.
- `components` are app-agnostic primitives. Where two apps differ, the
difference is an explicit **parameter** (e.g. `InlineTextField`'s
`capitalization`) or a **callback**, never a silently-picked winner. Apps keep
their own identity chips/icons and compose their own screens.
- **String resources** in the kit are English **fallbacks**; an app overrides
them (with its localized values) via resource merge. App-specific wording
stays in the app.
When a primitive is shared by adopting one app's version as canonical (e.g.
`components`' `GroupedSurface` follows Agendula's cleaner abstraction), that is a
deliberate, signed-off convergence — the other app's surfaces change when it
re-pins, reviewable at that point.

26
docs/README.md Normal file
View File

@@ -0,0 +1,26 @@
# floret-kit — documentation
floret-kit is the shared **Material 3 Expressive design system + plumbing** for
the Floret family of Android apps
([Calendula](https://gitea.jeanlucmakiola.de/makiolaj/calendula),
[Agendula](https://gitea.jeanlucmakiola.de/makiolaj/agendula), and future
siblings). It is consumed from source as a git submodule + Gradle composite
build. See the top-level [`../README.md`](../README.md) for the pitch.
## Index
| Doc | What it covers |
|---|---|
| [`ARCHITECTURE.md`](ARCHITECTURE.md) | How the kit is built and **consumed** — the module layering, JVM vs Android-library modules, the submodule + composite-build model, and the reproducibility rules. Start here. |
| [`ROADMAP.md`](ROADMAP.md) | **What's extracted, what's deferred, and what's deliberately not shared** — the module status and the divergence decisions from the cross-app survey. |
| [`../CONTRIBUTING.md`](../CONTRIBUTING.md) | The practical how — adding a module, the no-app-coupling rules, build/test, and how an app adopts the kit. |
| [`../CHANGELOG.md`](../CHANGELOG.md) | Keep a Changelog; what each module added. |
## The one principle
> Share the **mechanics**, keep the **look** per-app.
Theme machinery, plumbing, and component primitives are shared; seed colours,
palettes, identity chips, and app-specific wording stay in each app. This is what
lets the family read as one design language without homogenising apps that are
meant to have distinct personalities.

72
docs/ROADMAP.md Normal file
View File

@@ -0,0 +1,72 @@
# floret-kit — roadmap
What's extracted, what's deferred, and what's deliberately **not** shared. The
defer/skip calls come from a cross-app survey of both Calendula and Agendula —
the guiding rule is to share only what is genuinely common, and to keep
divergent or app-specific code where it lives.
---
## Extracted (done)
| Module | Type | Consumers |
|---|---|---|
| `core-time` | JVM | Agendula (and Calendula's pending branch) |
| `core-reminders` | JVM | Agendula (and Calendula's pending branch) |
| `core-crash` | Android | Agendula |
| `identity` | Android | Agendula |
| `components` | Android | Agendula |
See [`../CHANGELOG.md`](../CHANGELOG.md) for the contents of each.
---
## Deferred / not yet worth it
- **`core-prefs`** (DataStore base: `ThemeMode`, `dynamicColor`, the typed
wrapper, `toEnum()`) and **`core-di`** (`@IoDispatcher` + provider) — genuinely
shared but **low value**: pure relocation, no new capability, the shared
surface is tiny once the store name and bindings stay app-local. Sensible to
defer until a third app makes the duplication hurt.
- **`ReminderFormatting`** (`ReminderUnit` + `reminderLeadTimeLabel` +
`decomposeReminder`) — the labels are `@Composable` over app-specific
`R.plurals`, and the presets differ per app. Extract later behind a
**label/preset callback API**, not before.
## Deferred — needs migration first
- **`core-provider`** (the ContentProvider seam: `ColumnReader`, failures,
observer→Flow) — the two apps' `ColumnReader`s diverged (Calendula index-based,
Agendula name-based + null-safe). True sharing means migrating Calendula to the
better name-based design first (~a few hundred LOC), so it waits until Calendula
is actively on the kit.
## Not shared (by design)
- **A generic Settings scaffold** — the two apps' settings *domains* are too
divergent for a config-driven `SettingsScaffold` to stay readable. The win is
captured instead at the **component** level (shared `GroupedRow`/section
primitives + `CollapsingScaffold` + `OptionPicker`); each app composes its own
settings from those.
- **The reminder *scheduler*** — the shared `core-reminders` module is the
app-agnostic override *model* only (`ReminderOverride` + codec + unit model,
now multi-value). The *delivery* stays app-local: Agendula self-schedules
alarms (pull), Calendula is provider-event-driven (push) — opposite
architectures over incompatible data models, so forcing a shared scheduler
abstraction would be leaky. A tiny `core-notification` (idempotent channel
setup, `POST_NOTIFICATIONS` checks) *might* land later; schedulers stay
app-local.
- Domain-specific UI (date/time fields tied to a domain, calendar/timeline
widgets, recurrence text, domain pickers) and all string wording — these encode
each app's model and resist parameterization.
---
## Consumers & cadence
- **Agendula** consumes the kit on `main` (active development).
- **Calendula** (ships via official F-Droid reproducible builds) has a pending
adoption branch. Bringing it onto the kit is where the canonical-`GroupedSurface`
convergence becomes visible, and where the F-Droid reproducibility checklist
(`submodules: true` in the recipe; the repro guard scanning the submodule)
applies — see [`ARCHITECTURE.md`](ARCHITECTURE.md) §2 and §5.

View File

@@ -21,6 +21,7 @@ androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", versi
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-foundation = { group = "androidx.compose.foundation", name = "foundation" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" }
androidx-animation = { group = "androidx.compose.animation", name = "animation" }
androidx-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" }
junit-jupiter-api = { group = "org.junit.jupiter", name = "junit-jupiter-api", version.ref = "junit" }

View File

@@ -36,6 +36,7 @@ dependencies {
implementation(libs.androidx.ui)
implementation(libs.androidx.foundation)
implementation(libs.androidx.material3)
implementation(libs.androidx.animation)
// activity-compose for PredictiveBackHandler + BackEventCompat (the back-gesture peek).
implementation(libs.androidx.activity.compose)
}

View File

@@ -3,9 +3,12 @@ package de.jeanlucmakiola.floret.identity
import android.provider.Settings
import androidx.activity.BackEventCompat
import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -14,6 +17,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import kotlin.coroutines.cancellation.CancellationException
@@ -36,6 +40,21 @@ fun rememberReduceMotion(): Boolean {
}
}
/**
* Animates a composable's own size changes with the family's spatial motion, so
* a block that grows or shrinks — a text field wrapping to a new line, a value
* swapped for a longer one — eases into its new height instead of jumping. Wraps
* Compose's [androidx.compose.animation.animateContentSize] onto the active M3
* Expressive motion scheme; under reduced motion the new size snaps in.
*
* Apply it high in the modifier chain of the element whose height changes.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun Modifier.animateContentSizeMotion(reduceMotion: Boolean = rememberReduceMotion()): Modifier =
if (reduceMotion) this
else animateContentSize(animationSpec = MaterialTheme.motionScheme.fastSpatialSpec<IntSize>())
/**
* The standard Android predictive-back transform for a full-screen surface: as
* the back gesture is dragged, the surface scales toward ~90%, shifts toward the

View File

@@ -0,0 +1,91 @@
package de.jeanlucmakiola.floret.identity
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
/**
* The family's content transitions, driven by the active M3 Expressive motion
* scheme so every app reveals, collapses and re-lays-out the same way. Each
* honours [rememberReduceMotion]: when the user has asked the system to remove
* animations, the spatial movement is dropped and only a quick fade remains.
*
* Drawn from Calendula's motion helpers; pair [expandEnter] with [collapseExit]
* on an `AnimatedVisibility`, use [itemEnter] for content revealed once it
* resolves, [animateItemMotion] for `LazyColumn`/`LazyRow` row relayout, and
* [fadeThrough] to swap whole blocks with no spatial direction.
*/
/**
* Enter for a vertically-revealed section (expandable rows, inline fields):
* height grows from the top while fading in; a quick fade only under reduced
* motion. Pair with [collapseExit].
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun expandEnter(reduceMotion: Boolean = rememberReduceMotion()): EnterTransition {
val fade = fadeIn(MaterialTheme.motionScheme.fastEffectsSpec())
return if (reduceMotion) fade else expandVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + fade
}
/** Exit counterpart to [expandEnter]: shrink + fade, or fade only under reduced motion. */
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun collapseExit(reduceMotion: Boolean = rememberReduceMotion()): ExitTransition {
val fade = fadeOut(MaterialTheme.motionScheme.fastEffectsSpec())
return if (reduceMotion) fade else shrinkVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + fade
}
/**
* Enter for content revealed by an `AnimatedContent`/`AnimatedVisibility` (e.g.
* results once a query resolves): a gentle rise + fade; fade only under reduced
* motion.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun itemEnter(reduceMotion: Boolean = rememberReduceMotion()): EnterTransition {
val fade = fadeIn(MaterialTheme.motionScheme.fastEffectsSpec())
return if (reduceMotion) fade else fade + slideInVertically(MaterialTheme.motionScheme.fastSpatialSpec()) { h -> h / 6 }
}
/**
* `LazyItemScope.animateItem` wired to the app's motion scheme instead of
* Compose's default spring, so list rows fade/relocate consistently. Returns a
* bare [Modifier] under reduced motion so rows snap into place. Requires stable
* item keys.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun LazyItemScope.animateItemMotion(reduceMotion: Boolean = rememberReduceMotion()): Modifier =
if (reduceMotion) {
Modifier
} else {
Modifier.animateItem(
fadeInSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
placementSpec = MaterialTheme.motionScheme.fastSpatialSpec(),
fadeOutSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
)
}
/**
* Cross-fade [ContentTransform] for swapping whole screens or blocks with no
* meaningful spatial direction (e.g. onboarding gates). Pure opacity, so it
* doubles as its own reduced-motion form.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun fadeThrough(): ContentTransform {
val fade = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
return fadeIn(fade).togetherWith(fadeOut(fade))
}