Compare commits
27 Commits
19c2f8a677
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 60dd4eea71 | |||
| 2124227a7f | |||
| 78c5fd1fd4 | |||
| e1919cab83 | |||
| 55ad536513 | |||
| cded44207c | |||
| 5a576c4d28 | |||
| 2e47271904 | |||
| 89e3deb9f6 | |||
| 53217f139e | |||
| bd874c2586 | |||
| 566caf4305 | |||
| d9e4e877cc | |||
| aee26a28f3 | |||
| 86f44805d1 | |||
| 09d3f9b934 | |||
| 24ada183e2 | |||
| 1e36c229ca | |||
| ea96e970c7 | |||
| 2085b82a5e | |||
| 10e0ca0b06 | |||
| 4eb9809993 | |||
| 367a8ff8af | |||
| 25ec248e73 | |||
| 0db0e3883f | |||
| 1bd3e03ad5 | |||
| dccf934172 |
81
CHANGELOG.md
Normal file
81
CHANGELOG.md
Normal 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
77
CONTRIBUTING.md
Normal 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
21
LICENSE
Normal 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.
|
||||||
31
README.md
31
README.md
@@ -25,18 +25,37 @@ implementation("de.jeanlucmakiola.floret:core-time")
|
|||||||
```
|
```
|
||||||
|
|
||||||
Gradle's dependency substitution maps `de.jeanlucmakiola.floret:<module>` to the
|
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
|
## Modules
|
||||||
|
|
||||||
| Module | What it holds |
|
| Module | Type | 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-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/reminders/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
|
## Build
|
||||||
|
|
||||||
```sh
|
```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)
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -10,5 +10,5 @@ plugins {
|
|||||||
|
|
||||||
subprojects {
|
subprojects {
|
||||||
group = "de.jeanlucmakiola.floret"
|
group = "de.jeanlucmakiola.floret"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
}
|
}
|
||||||
|
|||||||
47
components/build.gradle.kts
Normal file
47
components/build.gradle.kts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
// components — the family's shared Compose component vocabulary: the grouped-row
|
||||||
|
// primitive (GroupedSurface/GroupedRow, the press-morphing tonal container), the
|
||||||
|
// borderless InlineTextField, the OptionCard selection-dialog pick, and the
|
||||||
|
// pastelize() colour softener. App-agnostic by design; each app composes its own
|
||||||
|
// screens (and keeps its own identity chips/icons) from these primitives.
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.library)
|
||||||
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "de.jeanlucmakiola.floret.components"
|
||||||
|
compileSdk = 37
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
minSdk = 29
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
compilerOptions {
|
||||||
|
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
implementation(libs.androidx.material3)
|
||||||
|
implementation(libs.androidx.material.icons.core)
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
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
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
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
|
||||||
|
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. 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
|
||||||
|
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 = 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 = {
|
||||||
|
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
|
||||||
|
.padding(innerPadding)
|
||||||
|
// Consume the scaffold's bar insets so imePadding adds only the
|
||||||
|
// keyboard height beyond them (max, not sum).
|
||||||
|
.consumeWindowInsets(innerPadding)
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
|
.imePadding()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(top = 8.dp, bottom = 24.dp),
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package de.jeanlucmakiola.floret.components
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soften a raw provider colour toward a pastel that fits the active theme —
|
||||||
|
* keeps the hue (entries stay recognisable), caps saturation so harsh sync
|
||||||
|
* colours stop screaming, and pins brightness to read on light and dark. Shared
|
||||||
|
* so the family's colours behave identically. Each app wraps it in its own
|
||||||
|
* identity chips (calendar dot, task glyph, …).
|
||||||
|
*/
|
||||||
|
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))
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package de.jeanlucmakiola.floret.components
|
||||||
|
|
||||||
|
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.fillMaxWidth
|
||||||
|
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.FilledTonalButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
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.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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The expanded "Custom" editor card shared by the family's presets-plus-custom
|
||||||
|
* pickers (reminder lead time, agenda range, snooze delay). A tonal surface with
|
||||||
|
* the connected-shape that meets the "Custom" row above it (tight top corners,
|
||||||
|
* full bottom), holding — top to bottom — an optional single-choice unit toggle,
|
||||||
|
* then a row of [DialogAmountField] + a live [preview] of what the amount
|
||||||
|
* resolves to + a tonal confirm.
|
||||||
|
*
|
||||||
|
* Purely presentational: the caller owns the amount/unit state and computes
|
||||||
|
* [preview] and [confirmEnabled] from its own domain, so this component carries
|
||||||
|
* no units, ranges, or strings of its own. Pass [unitLabels] empty for an
|
||||||
|
* amount-only editor (no toggle row). [onConfirm] fires on the confirm button.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CustomAmountEditor(
|
||||||
|
amountText: String,
|
||||||
|
onAmountChange: (String) -> Unit,
|
||||||
|
preview: String,
|
||||||
|
setLabel: String,
|
||||||
|
confirmEnabled: Boolean,
|
||||||
|
onConfirm: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
placeholder: String = "10",
|
||||||
|
unitLabels: List<String> = emptyList(),
|
||||||
|
selectedUnit: Int = 0,
|
||||||
|
onUnitChange: (Int) -> Unit = {},
|
||||||
|
) {
|
||||||
|
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),
|
||||||
|
) {
|
||||||
|
// Unit toggle first (when present) so it stays visible above the
|
||||||
|
// keyboard once the amount field below it is focused and scrolled in.
|
||||||
|
if (unitLabels.isNotEmpty()) {
|
||||||
|
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
unitLabels.forEachIndexed { index, label ->
|
||||||
|
SegmentedButton(
|
||||||
|
selected = index == selectedUnit,
|
||||||
|
onClick = { onUnitChange(index) },
|
||||||
|
shape = SegmentedButtonDefaults.itemShape(index, unitLabels.size),
|
||||||
|
label = { Text(label) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Amount, a live preview of what it resolves to, and confirm — all on
|
||||||
|
// one row, sitting just above the keyboard.
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
DialogAmountField(
|
||||||
|
value = amountText,
|
||||||
|
onValueChange = onAmountChange,
|
||||||
|
placeholder = placeholder,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
Text(
|
||||||
|
text = preview,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
FilledTonalButton(onClick = onConfirm, enabled = confirmEnabled) {
|
||||||
|
Text(setLabel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
package de.jeanlucmakiola.floret.components
|
||||||
|
|
||||||
|
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. The family's
|
||||||
|
* canonical grouped-row primitive.
|
||||||
|
*/
|
||||||
|
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.
|
||||||
|
*
|
||||||
|
* 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
|
||||||
|
fun GroupedSurface(
|
||||||
|
position: Position,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onClick: (() -> Unit)? = null,
|
||||||
|
color: Color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
gapBelow: Boolean = true,
|
||||||
|
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 {
|
||||||
|
!gapBelow -> Modifier
|
||||||
|
position == Position.Top || position == Position.Middle -> Modifier.padding(bottom = 2.dp)
|
||||||
|
else -> 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.
|
||||||
|
*
|
||||||
|
* [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
|
||||||
|
fun GroupedRow(
|
||||||
|
title: String,
|
||||||
|
position: Position,
|
||||||
|
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,
|
||||||
|
) {
|
||||||
|
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 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 = 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) },
|
||||||
|
supportingContent = summary?.let { text -> { Text(text) } },
|
||||||
|
leadingContent = leading,
|
||||||
|
trailingContent = trailing,
|
||||||
|
colors = itemColors,
|
||||||
|
modifier = Modifier.heightIn(min = minHeight),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package de.jeanlucmakiola.floret.components
|
||||||
|
|
||||||
|
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 family's borderless text input: no underline, no outline, just the tonal
|
||||||
|
* card behind it. Sit it inside a tonal [androidx.compose.material3.Surface] so
|
||||||
|
* anything that takes text reads as one family rather than a boxed Material
|
||||||
|
* field. [capitalization] is a parameter so each app picks its convention
|
||||||
|
* (sentence-case by default).
|
||||||
|
*/
|
||||||
|
@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,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
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 = 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,
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package de.jeanlucmakiola.floret.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/** MD3 8dp spacing scale shared by the onboarding screens. */
|
||||||
|
object OnboardingSpace {
|
||||||
|
val xs = 8.dp
|
||||||
|
val sm = 16.dp
|
||||||
|
val md = 24.dp
|
||||||
|
val lg = 32.dp
|
||||||
|
val xl = 48.dp
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared onboarding shell: a scrollable, centred [hero] + [body] with the
|
||||||
|
* call(s) to action ([actions]) pinned to the bottom (clear of the navigation
|
||||||
|
* bar). The content slot is centred horizontally; benefit rows fill the width so
|
||||||
|
* their own content left-aligns. Each app supplies its own hero so the apps read
|
||||||
|
* as one family while keeping their own mark.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun OnboardingScaffold(
|
||||||
|
hero: @Composable () -> Unit,
|
||||||
|
actions: @Composable ColumnScope.() -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
body: @Composable ColumnScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
bottomBar = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.padding(horizontal = OnboardingSpace.md, vertical = OnboardingSpace.sm),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
content = actions,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = OnboardingSpace.md),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Spacer(Modifier.height(OnboardingSpace.xl))
|
||||||
|
hero()
|
||||||
|
Spacer(Modifier.height(OnboardingSpace.lg))
|
||||||
|
body()
|
||||||
|
Spacer(Modifier.height(OnboardingSpace.md))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One trust point: a tonal icon chip on the left, title + supporting text right. */
|
||||||
|
@Composable
|
||||||
|
fun BenefitRow(icon: ImageVector, title: String, body: String) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(44.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.secondaryContainer),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(OnboardingSpace.sm))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(text = title, style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
text = body,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package de.jeanlucmakiola.floret.components
|
||||||
|
|
||||||
|
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 family'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).
|
||||||
|
*/
|
||||||
|
@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
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
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] 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(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
properties = DialogProperties(
|
||||||
|
usePlatformDefaultWidth = false,
|
||||||
|
decorFitsSystemWindows = false,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
// 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* General single-select picker, full-screen: each option is a connected grouped
|
||||||
|
* row and the current one carries a check. The drop-in for the family's option
|
||||||
|
* dialogs (theme, default list, reminder offset, …).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun <T> OptionPicker(
|
||||||
|
title: String,
|
||||||
|
options: List<T>,
|
||||||
|
selected: T,
|
||||||
|
label: @Composable (T) -> String,
|
||||||
|
onSelect: (T) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
leading: (@Composable (T) -> Unit)? = null,
|
||||||
|
header: (@Composable ColumnScope.() -> Unit)? = null,
|
||||||
|
predictiveBack: Boolean = false,
|
||||||
|
) {
|
||||||
|
FullScreenPicker(title = title, onDismiss = onDismiss, predictiveBack = predictiveBack) {
|
||||||
|
header?.invoke(this)
|
||||||
|
options.forEachIndexed { index, option ->
|
||||||
|
val isSelected = option == selected
|
||||||
|
GroupedRow(
|
||||||
|
title = label(option),
|
||||||
|
position = positionOf(index, options.size),
|
||||||
|
selected = isSelected,
|
||||||
|
leading = leading?.let { { it(option) } },
|
||||||
|
trailing = if (isSelected) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
onSelect(option)
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SelectedCheck() {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Rounded.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
7
components/src/main/res/values/strings.xml
Normal file
7
components/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<resources>
|
||||||
|
<!--
|
||||||
|
Generic component strings. App-agnostic fallbacks; an app may override any
|
||||||
|
of these with its own (localized) value via resource merge.
|
||||||
|
-->
|
||||||
|
<string name="back">Back</string>
|
||||||
|
</resources>
|
||||||
46
core-locale/build.gradle.kts
Normal file
46
core-locale/build.gradle.kts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
// core-locale — per-app language plumbing shared across the family: read the
|
||||||
|
// shipped languages from res/xml/locales_config.xml, get/set the applied
|
||||||
|
// language via AppCompatDelegate, and render each language's autonym. App-
|
||||||
|
// agnostic — the consuming app passes its own locales_config resource id; no
|
||||||
|
// Compose, no DataStore.
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.library)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "de.jeanlucmakiola.floret.locale"
|
||||||
|
compileSdk = 37
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
minSdk = 29
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
testOptions {
|
||||||
|
unitTests {
|
||||||
|
all { it.useJUnitPlatform() }
|
||||||
|
isReturnDefaultValues = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
compilerOptions {
|
||||||
|
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// appcompat brings AppCompatDelegate plus its core (LocaleListCompat) and
|
||||||
|
// annotation (XmlRes) transitives.
|
||||||
|
implementation(libs.androidx.appcompat)
|
||||||
|
|
||||||
|
testImplementation(libs.junit.jupiter.api)
|
||||||
|
testRuntimeOnly(libs.junit.jupiter.engine)
|
||||||
|
testRuntimeOnly(libs.junit.platform.launcher)
|
||||||
|
testImplementation(libs.truth)
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package de.jeanlucmakiola.floret.locale
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.annotation.XmlRes
|
||||||
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
|
import androidx.core.os.LocaleListCompat
|
||||||
|
import org.xmlpull.v1.XmlPullParser
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
private const val ANDROID_NS = "http://schemas.android.com/apk/res/android"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-app language via AppCompatDelegate, driven by an app's
|
||||||
|
* res/xml/locales_config.xml.
|
||||||
|
*
|
||||||
|
* That file is the single source of truth for which languages an app ships:
|
||||||
|
* dropping in a values-<tag> translation and adding a matching `<locale>` entry
|
||||||
|
* makes the language show up here and in the system per-app-language settings,
|
||||||
|
* with no other code change. The system-default choice is represented as `null`.
|
||||||
|
*
|
||||||
|
* On API 33+ this delegates to the platform per-app-languages API; below that
|
||||||
|
* the appcompat backport persists the choice itself (manifest `autoStoreLocales`
|
||||||
|
* service), so we don't mirror it in DataStore. Setting a locale recreates the
|
||||||
|
* activity, which re-reads the current value for the picker.
|
||||||
|
*/
|
||||||
|
object AppLanguage {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The BCP-47 tags the app ships translations for, in declaration order, as
|
||||||
|
* listed in [localesConfig] (the app's `res/xml/locales_config.xml`). Returns
|
||||||
|
* whatever could be parsed; a missing or malformed config yields an empty
|
||||||
|
* list (the picker then offers only the system-default entry rather than
|
||||||
|
* crashing).
|
||||||
|
*/
|
||||||
|
fun supportedTags(context: Context, @XmlRes localesConfig: Int): List<String> {
|
||||||
|
val tags = mutableListOf<String>()
|
||||||
|
val parser = context.resources.getXml(localesConfig)
|
||||||
|
try {
|
||||||
|
var event = parser.eventType
|
||||||
|
while (event != XmlPullParser.END_DOCUMENT) {
|
||||||
|
if (event == XmlPullParser.START_TAG && parser.name == "locale") {
|
||||||
|
parser.getAttributeValue(ANDROID_NS, "name")?.let(tags::add)
|
||||||
|
}
|
||||||
|
event = parser.next()
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// Fall back to whatever was parsed before the failure.
|
||||||
|
} finally {
|
||||||
|
parser.close()
|
||||||
|
}
|
||||||
|
return tags
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The applied app language as a BCP-47 tag, or `null` when following the system. */
|
||||||
|
fun currentTag(): String? {
|
||||||
|
val locales = AppCompatDelegate.getApplicationLocales()
|
||||||
|
return if (locales.isEmpty) null else locales[0]?.toLanguageTag()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply a BCP-47 tag, or `null` to follow the system languages. */
|
||||||
|
fun apply(tag: String?) {
|
||||||
|
val locales = if (tag == null) {
|
||||||
|
LocaleListCompat.getEmptyLocaleList()
|
||||||
|
} else {
|
||||||
|
LocaleListCompat.forLanguageTags(tag)
|
||||||
|
}
|
||||||
|
AppCompatDelegate.setApplicationLocales(locales)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The autonym for a tag — the language's own name in its own script, e.g.
|
||||||
|
* "Deutsch", "English", "Français" — so users find their language regardless
|
||||||
|
* of the current UI language. Capitalised per the language's own rules.
|
||||||
|
*/
|
||||||
|
fun displayName(tag: String): String {
|
||||||
|
val locale = Locale.forLanguageTag(tag)
|
||||||
|
return locale.getDisplayName(locale)
|
||||||
|
.replaceFirstChar { if (it.isLowerCase()) it.titlecase(locale) else it.toString() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package de.jeanlucmakiola.floret.locale
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class AppLanguageTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `autonym is the language's own name`() {
|
||||||
|
assertThat(AppLanguage.displayName("en")).isEqualTo("English")
|
||||||
|
assertThat(AppLanguage.displayName("de")).isEqualTo("Deutsch")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `autonym is capitalised per the language's own rules`() {
|
||||||
|
// French autonyms are lower-case in CLDR ("français"); the picker shows
|
||||||
|
// them title-cased so each language reads as a proper name.
|
||||||
|
assertThat(AppLanguage.displayName("fr")).isEqualTo("Français")
|
||||||
|
}
|
||||||
|
}
|
||||||
32
core-reminders/build.gradle.kts
Normal file
32
core-reminders/build.gradle.kts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
// core-reminders — pure-Kotlin reminder-lead plumbing shared across the family:
|
||||||
|
// the lead-time unit model (ReminderUnit / decomposeReminderMinutes), the
|
||||||
|
// per-target override model (ReminderOverride + resolution helpers) and the
|
||||||
|
// stored-format codec (ReminderOverrideCodec, separators configurable so each
|
||||||
|
// app keeps its on-disk format). No Android, no third-party deps — a plain JVM
|
||||||
|
// library, like core-time; each app layers its own DataStore + string labels on
|
||||||
|
// top.
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.jvm)
|
||||||
|
}
|
||||||
|
|
||||||
|
java {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
compilerOptions {
|
||||||
|
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
testImplementation(libs.junit.jupiter.api)
|
||||||
|
testRuntimeOnly(libs.junit.jupiter.engine)
|
||||||
|
testRuntimeOnly(libs.junit.platform.launcher)
|
||||||
|
testImplementation(libs.truth)
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.test {
|
||||||
|
useJUnitPlatform()
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package de.jeanlucmakiola.floret.reminders
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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. */
|
||||||
|
data object Inherit : ReminderOverride
|
||||||
|
|
||||||
|
/** Explicit "no reminder" for this target, regardless of the global default. */
|
||||||
|
data object None : ReminderOverride
|
||||||
|
|
||||||
|
/** Specific lead times in minutes before due (non-empty, distinct, ascending). */
|
||||||
|
data class Minutes(val minutes: List<Int>) : ReminderOverride
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Distinct, ascending lead times — the canonical form stored and resolved. */
|
||||||
|
fun List<Int>.normalizeReminders(): List<Int> = distinct().sorted()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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, 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
|
||||||
|
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, List<Int>>.applyReminderOverride(id: Long, override: ReminderOverride) {
|
||||||
|
when (override) {
|
||||||
|
ReminderOverride.Inherit -> remove(id)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package de.jeanlucmakiola.floret.reminders
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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"`.
|
||||||
|
*
|
||||||
|
* 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 (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
|
||||||
|
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, 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 `;`. */
|
||||||
|
val DEFAULT = ReminderOverrideCodec()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package de.jeanlucmakiola.floret.reminders
|
||||||
|
|
||||||
|
/** The unit of a custom reminder lead time; [minutesFactor] converts to minutes. */
|
||||||
|
enum class ReminderUnit(val minutesFactor: Int) {
|
||||||
|
Minutes(1),
|
||||||
|
Hours(60),
|
||||||
|
Days(1_440),
|
||||||
|
Weeks(10_080),
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A reminder lead time broken into a whole [amount] of a [unit] — the seed for a
|
||||||
|
* custom-amount editor. [amount] is null when there is no lead time to show.
|
||||||
|
*/
|
||||||
|
data class ReminderAmount(val amount: Int?, val unit: ReminderUnit)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decompose a reminder lead time (minutes before due) into the largest unit that
|
||||||
|
* divides it evenly: 120 → 2 hours, 1 440 → 1 day, 10 080 → 1 week, 90 → 90
|
||||||
|
* minutes. null or a non-positive lead time → no amount, defaulting to minutes.
|
||||||
|
*/
|
||||||
|
fun decomposeReminderMinutes(minutes: Int?): ReminderAmount = when {
|
||||||
|
minutes == null || minutes <= 0 -> ReminderAmount(null, ReminderUnit.Minutes)
|
||||||
|
minutes % ReminderUnit.Weeks.minutesFactor == 0 ->
|
||||||
|
ReminderAmount(minutes / ReminderUnit.Weeks.minutesFactor, ReminderUnit.Weeks)
|
||||||
|
minutes % ReminderUnit.Days.minutesFactor == 0 ->
|
||||||
|
ReminderAmount(minutes / ReminderUnit.Days.minutesFactor, ReminderUnit.Days)
|
||||||
|
minutes % ReminderUnit.Hours.minutesFactor == 0 ->
|
||||||
|
ReminderAmount(minutes / ReminderUnit.Hours.minutesFactor, ReminderUnit.Hours)
|
||||||
|
else -> ReminderAmount(minutes, ReminderUnit.Minutes)
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package de.jeanlucmakiola.floret.reminders
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class ReminderModelTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `decompose picks the largest whole unit`() {
|
||||||
|
assertThat(decomposeReminderMinutes(120)).isEqualTo(ReminderAmount(2, ReminderUnit.Hours))
|
||||||
|
assertThat(decomposeReminderMinutes(1_440)).isEqualTo(ReminderAmount(1, ReminderUnit.Days))
|
||||||
|
assertThat(decomposeReminderMinutes(10_080)).isEqualTo(ReminderAmount(1, ReminderUnit.Weeks))
|
||||||
|
assertThat(decomposeReminderMinutes(90)).isEqualTo(ReminderAmount(90, ReminderUnit.Minutes))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `decompose of null or non-positive has no amount`() {
|
||||||
|
assertThat(decomposeReminderMinutes(null)).isEqualTo(ReminderAmount(null, ReminderUnit.Minutes))
|
||||||
|
assertThat(decomposeReminderMinutes(0)).isEqualTo(ReminderAmount(null, ReminderUnit.Minutes))
|
||||||
|
assertThat(decomposeReminderMinutes(-5)).isEqualTo(ReminderAmount(null, ReminderUnit.Minutes))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `normalize dedupes and sorts`() {
|
||||||
|
assertThat(listOf(60, 10, 60, 0).normalizeReminders()).containsExactly(0, 10, 60).inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
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 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.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package de.jeanlucmakiola.floret.reminders
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class ReminderOverrideCodecTest {
|
||||||
|
|
||||||
|
private val codec = ReminderOverrideCodec.DEFAULT
|
||||||
|
|
||||||
|
@Test
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `null or blank parses to empty`() {
|
||||||
|
assertThat(codec.parse(null)).isEmpty()
|
||||||
|
assertThat(codec.parse("")).isEmpty()
|
||||||
|
assertThat(codec.parse(" ")).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `empty map serializes to empty string`() {
|
||||||
|
assertThat(codec.serialize(emptyMap())).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `none token decodes to an empty-list value`() {
|
||||||
|
assertThat(codec.parse("7:none")).isEqualTo(mapOf(7L to emptyList<Int>()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
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 = "=", listSep = ",", noneToken = "none")
|
||||||
|
val map = mapOf(12L to listOf(30, 60), 7L to emptyList())
|
||||||
|
val stored = calendula.serialize(map)
|
||||||
|
|
||||||
|
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.
|
||||||
|
assertThat(codec.parse(stored)).isEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
package de.jeanlucmakiola.floret.time
|
package de.jeanlucmakiola.floret.time
|
||||||
|
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalTime
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
|
import java.time.chrono.IsoChronology
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.DateTimeFormatterBuilder
|
||||||
import java.time.format.FormatStyle
|
import java.time.format.FormatStyle
|
||||||
|
import java.util.Locale
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,3 +31,28 @@ fun Instant.formatTime(): String = atSystemZone().format(timeFormatter)
|
|||||||
/** Date alone for all-day items, otherwise date + time. */
|
/** Date alone for all-day items, otherwise date + time. */
|
||||||
fun Instant.formatDateTime(allDay: Boolean): String =
|
fun Instant.formatDateTime(allDay: Boolean): String =
|
||||||
if (allDay) formatDate() else "${formatDate()} · ${formatTime()}"
|
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()}"
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
147
docs/ARCHITECTURE.md
Normal 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
26
docs/README.md
Normal 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
72
docs/ROADMAP.md
Normal 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.
|
||||||
@@ -4,6 +4,8 @@
|
|||||||
agp = "9.2.1"
|
agp = "9.2.1"
|
||||||
kotlin = "2.3.21"
|
kotlin = "2.3.21"
|
||||||
coreKtx = "1.19.0"
|
coreKtx = "1.19.0"
|
||||||
|
appcompat = "1.7.1"
|
||||||
|
activityCompose = "1.13.0"
|
||||||
composeBom = "2026.05.01"
|
composeBom = "2026.05.01"
|
||||||
# Material 3 Expressive APIs live in the 1.5 alpha line; pinned to override the BOM.
|
# Material 3 Expressive APIs live in the 1.5 alpha line; pinned to override the BOM.
|
||||||
material3 = "1.5.0-alpha21"
|
material3 = "1.5.0-alpha21"
|
||||||
@@ -13,10 +15,14 @@ truth = "1.4.5"
|
|||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
|
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
|
||||||
|
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||||
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||||
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
|
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||||
androidx-foundation = { group = "androidx.compose.foundation", name = "foundation" }
|
androidx-foundation = { group = "androidx.compose.foundation", name = "foundation" }
|
||||||
androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" }
|
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" }
|
junit-jupiter-api = { group = "org.junit.jupiter", name = "junit-jupiter-api", version.ref = "junit" }
|
||||||
junit-jupiter-engine = { group = "org.junit.jupiter", name = "junit-jupiter-engine", version.ref = "junit" }
|
junit-jupiter-engine = { group = "org.junit.jupiter", name = "junit-jupiter-engine", version.ref = "junit" }
|
||||||
|
|||||||
42
identity/build.gradle.kts
Normal file
42
identity/build.gradle.kts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
// identity — the family's Material 3 Expressive theme factory + shared motion.
|
||||||
|
// Holds the theme MECHANICS (dynamic-colour resolution, light/dark, the STANDARD
|
||||||
|
// motion scheme) and the nav slide spec; each app supplies its own seed-derived
|
||||||
|
// colour schemes, so the look stays per-app while the machinery is shared.
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.library)
|
||||||
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "de.jeanlucmakiola.floret.identity"
|
||||||
|
compileSdk = 37
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
minSdk = 29
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
compilerOptions {
|
||||||
|
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
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
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the user has asked the system to remove animations (Settings →
|
||||||
|
* Accessibility → "Remove animations", which sets the global animator duration
|
||||||
|
* scale to 0). Compose animations do *not* honour this platform flag on their
|
||||||
|
* own, so the shared motion helpers check it and fall back to no spatial
|
||||||
|
* movement — respecting the vestibular intent of the setting while keeping state
|
||||||
|
* changes legible.
|
||||||
|
*
|
||||||
|
* Read once at composition; the scale changes rarely and only takes full effect
|
||||||
|
* after a process restart anyway.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun rememberReduceMotion(): Boolean {
|
||||||
|
val resolver = LocalContext.current.contentResolver
|
||||||
|
return remember(resolver) {
|
||||||
|
Settings.Global.getFloat(resolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* swiped edge and rounds its corners, previewing what's behind. Completing the
|
||||||
|
* gesture invokes [onBack]; cancelling springs it back.
|
||||||
|
*
|
||||||
|
* A drop-in replacement for a screen's own `BackHandler(onBack)` — register it
|
||||||
|
* once and apply the returned [Modifier] to that screen's root so the preview
|
||||||
|
* respects the same back semantics. Under reduced motion the visual preview is
|
||||||
|
* skipped (the back still works); on API < 34 the system delivers no progress,
|
||||||
|
* so it degrades to a plain back. Shared so the family's apps preview the same
|
||||||
|
* way.
|
||||||
|
*
|
||||||
|
* @param enabled gate the handler — e.g. set false while a screen's own inner
|
||||||
|
* back (a sub-section) should take the gesture instead.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun Modifier.predictiveBack(
|
||||||
|
onBack: () -> Unit,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
reduceMotion: Boolean = rememberReduceMotion(),
|
||||||
|
): Modifier {
|
||||||
|
val progress = remember { Animatable(0f) }
|
||||||
|
var fromLeftEdge by remember { mutableStateOf(true) }
|
||||||
|
|
||||||
|
PredictiveBackHandler(enabled = enabled) { events ->
|
||||||
|
try {
|
||||||
|
events.collect { event ->
|
||||||
|
fromLeftEdge = event.swipeEdge == BackEventCompat.EDGE_LEFT
|
||||||
|
progress.snapTo(FastOutSlowInEasing.transform(event.progress))
|
||||||
|
}
|
||||||
|
onBack()
|
||||||
|
progress.snapTo(0f)
|
||||||
|
} catch (_: CancellationException) {
|
||||||
|
progress.animateTo(0f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reduceMotion) return this
|
||||||
|
return this.graphicsLayer {
|
||||||
|
val p = progress.value
|
||||||
|
val scale = 1f - 0.1f * p
|
||||||
|
scaleX = scale
|
||||||
|
scaleY = scale
|
||||||
|
translationX = (if (fromLeftEdge) 1f else -1f) * 24.dp.toPx() * p
|
||||||
|
shape = RoundedCornerShape(32.dp.toPx() * p)
|
||||||
|
clip = p > 0f
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package de.jeanlucmakiola.floret.identity
|
||||||
|
|
||||||
|
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. Shared so the family's 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()
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package de.jeanlucmakiola.floret.identity
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.material3.ColorScheme
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.MaterialExpressiveTheme
|
||||||
|
import androidx.compose.material3.MotionScheme
|
||||||
|
import androidx.compose.material3.Typography
|
||||||
|
import androidx.compose.material3.dynamicDarkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicLightColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The family's Material 3 Expressive theme factory. Honours system light/dark
|
||||||
|
* and Material You dynamic colour (API 31+), falling back to the app-supplied
|
||||||
|
* [lightScheme] / [darkScheme] otherwise. Routes all component + custom motion
|
||||||
|
* through `MaterialTheme.motionScheme` with the **standard** scheme — spring
|
||||||
|
* choreography without the overshoot, the family's deliberate motion choice.
|
||||||
|
*
|
||||||
|
* The mechanics are shared; the look is not. Each app keeps its identity by
|
||||||
|
* passing its own hand-tuned, seed-derived [lightScheme] / [darkScheme] and an
|
||||||
|
* optional [typography]. A thin per-app wrapper (e.g. `AgendulaTheme`) supplies
|
||||||
|
* those and forwards [darkTheme] / [dynamicColor] from settings.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun FloretExpressiveTheme(
|
||||||
|
lightScheme: ColorScheme,
|
||||||
|
darkScheme: ColorScheme,
|
||||||
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
|
dynamicColor: Boolean = true,
|
||||||
|
typography: Typography = Typography(),
|
||||||
|
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 -> darkScheme
|
||||||
|
else -> lightScheme
|
||||||
|
}
|
||||||
|
|
||||||
|
MaterialExpressiveTheme(
|
||||||
|
colorScheme = colorScheme,
|
||||||
|
typography = typography,
|
||||||
|
motionScheme = MotionScheme.standard(),
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -32,4 +32,8 @@ dependencyResolutionManagement {
|
|||||||
rootProject.name = "floret-kit"
|
rootProject.name = "floret-kit"
|
||||||
|
|
||||||
include(":core-time")
|
include(":core-time")
|
||||||
|
include(":core-reminders")
|
||||||
|
include(":core-locale")
|
||||||
include(":core-crash")
|
include(":core-crash")
|
||||||
|
include(":identity")
|
||||||
|
include(":components")
|
||||||
|
|||||||
Reference in New Issue
Block a user