Compare commits
31 Commits
d3ae83f1a4
...
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 | |||
| 19c2f8a677 | |||
| 554d536b2f | |||
| 39cdfbcb71 | |||
| 7efad77fde |
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
|
||||
local source module — edit the kit and the app picks it up immediately.
|
||||
local source module — edit the kit and the app picks it up immediately. Android
|
||||
modules need an SDK location for the included build: set `ANDROID_HOME` (CI) or
|
||||
add a gitignored `<app>/floret-kit/local.properties` with `sdk.dir` (locally).
|
||||
See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the full consumption model.
|
||||
|
||||
## Modules
|
||||
|
||||
| Module | What it holds |
|
||||
|--------|---------------|
|
||||
| `core-time` | Pure-Kotlin date/time helpers: local-day windows (`DayWindow`) for smart-list logic and locale/zone-aware display formatting (`Instant.formatDate()` / `formatTime()` / `formatDateTime()`). No Android, no deps. |
|
||||
| Module | Type | What it holds |
|
||||
|--------|------|---------------|
|
||||
| `core-time` | JVM | Date/time helpers: `DayWindow` local-day windows, locale/zone-aware `Instant` formatting, `TimeBridge` (millis ↔ `Instant`). No Android, no deps. |
|
||||
| `core-reminders` | JVM | Reminder-lead plumbing: the lead-time unit model (`ReminderUnit`, `decomposeReminderMinutes`), the per-target override model (`ReminderOverride` + `reminderLeadFor` / `applyReminderOverride`) and the stored-format codec (`ReminderOverrideCodec`, separators configurable per app). Each app layers its own DataStore + string labels on top. |
|
||||
| `core-locale` | Android | Per-app language plumbing (`AppLanguage`): read the shipped languages from the app's `res/xml/locales_config.xml`, get/set the applied language via `AppCompatDelegate`, render each language's autonym. The app passes its own `locales_config`; appcompat only, no Compose. |
|
||||
| `core-crash` | Android | Privacy-respecting on-device crash capture + report dialog + issue-tracker hand-off. Parameterized per app via `CrashConfig`. |
|
||||
| `identity` | Android | The M3 Expressive theme factory `FloretExpressiveTheme(…)`, `rememberNavSlideSpec()`, the content transitions (`expandEnter`/`collapseExit`/`itemEnter`/…) and the `predictiveBack` peek. Each app supplies its own seed/palette. |
|
||||
| `components` | Android | Shared Compose vocabulary + recipes: `GroupedSurface`/`GroupedRow`, `InlineTextField`, `OptionCard`, `CollapsingScaffold`, `OptionPicker`, `ReorderableColumn`, `DebugRibbon`, `pastelize()`, `DialogControls`, `OnboardingScaffold`, `OptionalFormSection`, `AboutCard`, `LanguagePickerRow`. |
|
||||
|
||||
_More modules (identity/theme, components, screen recipes, provider/prefs/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
|
||||
|
||||
```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)
|
||||
```
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
// consuming apps can depend on `de.jeanlucmakiola.floret:<module>` and Gradle's
|
||||
// composite-build substitution maps it to the local source module.
|
||||
plugins {
|
||||
alias(libs.plugins.android.library) apply false
|
||||
alias(libs.plugins.kotlin.compose) apply false
|
||||
alias(libs.plugins.kotlin.jvm) apply false
|
||||
}
|
||||
|
||||
subprojects {
|
||||
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>
|
||||
58
core-crash/build.gradle.kts
Normal file
58
core-crash/build.gradle.kts
Normal file
@@ -0,0 +1,58 @@
|
||||
// core-crash — privacy-respecting, on-device crash capture + a report dialog and
|
||||
// issue-tracker hand-off, shared across the family. The reusable machinery lives
|
||||
// here; each app keeps a thin themed CrashReportActivity and supplies a
|
||||
// [CrashConfig] (app label + issue-tracker URLs) at install time.
|
||||
//
|
||||
// First Android-library module in the kit. Deliberately lean: Compose + core-ktx
|
||||
// only, no buildConfig, no extra-icons dependency.
|
||||
plugins {
|
||||
// AGP 9.x provides built-in Kotlin compilation, so only the Compose plugin is
|
||||
// applied alongside the Android library plugin (matches the apps' setup).
|
||||
alias(libs.plugins.android.library)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "de.jeanlucmakiola.floret.crash"
|
||||
compileSdk = 37
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 29
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests {
|
||||
all { it.useJUnitPlatform() }
|
||||
isReturnDefaultValues = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.androidx.core.ktx)
|
||||
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.ui)
|
||||
implementation(libs.androidx.foundation)
|
||||
implementation(libs.androidx.material3)
|
||||
|
||||
testImplementation(libs.junit.jupiter.api)
|
||||
testRuntimeOnly(libs.junit.jupiter.engine)
|
||||
testRuntimeOnly(libs.junit.platform.launcher)
|
||||
testImplementation(libs.truth)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package de.jeanlucmakiola.floret.crash
|
||||
|
||||
/**
|
||||
* The per-app facts the shared crash machinery needs: the app's display name
|
||||
* (for the report header and labels) and its issue-tracker URLs. Supplied once
|
||||
* at [CrashReporter.install]; ambient thereafter, so the dialog and submit flow
|
||||
* read it without the caller threading it through.
|
||||
*
|
||||
* URLs are passed as plain strings (resolved from each app's own resources)
|
||||
* rather than kit resources, since they differ per app and are not translated.
|
||||
*/
|
||||
data class CrashConfig(
|
||||
/** Display name, e.g. "Agendula". Used in the report header and dialog copy. */
|
||||
val appLabel: String,
|
||||
/** Issue tracker "new issue" endpoint; the crash flow appends title + body. */
|
||||
val newIssueUrl: String,
|
||||
/** Issue tracker entry point for a manual (non-crash) report — e.g. a chooser. */
|
||||
val chooseIssueUrl: String,
|
||||
/** Title used for the prefilled crash issue, e.g. "Crash report". */
|
||||
val issueTitle: String,
|
||||
)
|
||||
@@ -0,0 +1,72 @@
|
||||
package de.jeanlucmakiola.floret.crash
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Asks the user to send a captured crash report as an issue. The full report is
|
||||
* shown verbatim in a scrollable panel — the user sees exactly what will leave
|
||||
* the device before choosing to share it (the privacy backstop). [onSend] hands
|
||||
* off to [submitCrashReport]; [onDismiss] declines. The app's display name is
|
||||
* read from the installed [CrashReporter.config].
|
||||
*/
|
||||
@Composable
|
||||
fun CrashReportDialog(
|
||||
report: String,
|
||||
onSend: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val appLabel = CrashReporter.config?.appLabel ?: stringResource(R.string.crash_app_fallback)
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.crash_dialog_title, appLabel)) },
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(R.string.crash_dialog_message, appLabel),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = report,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.heightIn(max = 220.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onSend) { Text(stringResource(R.string.crash_dialog_report)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.crash_dialog_dismiss)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package de.jeanlucmakiola.floret.crash
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.widget.Toast
|
||||
import androidx.core.net.toUri
|
||||
|
||||
/**
|
||||
* Hand the captured crash report off to the user's chosen channel: the report
|
||||
* is copied to the clipboard (the reliable path for a full stack trace) and the
|
||||
* project's "new issue" page is opened with the body prefilled. Nothing is sent
|
||||
* automatically — the apps have no network access; the user reviews and submits
|
||||
* the issue themselves. Issue-tracker URLs and the app label come from the
|
||||
* installed [CrashReporter.config].
|
||||
*/
|
||||
fun submitCrashReport(context: Context, report: String) {
|
||||
val config = CrashReporter.config ?: return
|
||||
copyReportToClipboard(context, config.appLabel, report)
|
||||
val opened = runCatching {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, buildIssueUri(context, config, report)))
|
||||
}.isSuccess
|
||||
val message = if (opened) R.string.crash_report_copied else R.string.crash_report_open_failed
|
||||
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
/** Open the issue tracker's entry point for a manual (non-crash) report. */
|
||||
fun openIssueTracker(context: Context) {
|
||||
val url = CrashReporter.config?.chooseIssueUrl ?: return
|
||||
runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri())) }
|
||||
}
|
||||
|
||||
private fun copyReportToClipboard(context: Context, appLabel: String, report: String) {
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
|
||||
val label = context.getString(R.string.crash_report_clip_label, appLabel)
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText(label, report))
|
||||
}
|
||||
|
||||
/**
|
||||
* The `issues/new` URL with `title` and `body` prefilled. A full report can blow
|
||||
* past URL-length limits, so an over-long one is left out of the link (with a
|
||||
* "paste from clipboard" placeholder) — the clipboard copy is the source of
|
||||
* truth in that case.
|
||||
*/
|
||||
private fun buildIssueUri(context: Context, config: CrashConfig, report: String) =
|
||||
config.newIssueUrl.toUri().buildUpon()
|
||||
.appendQueryParameter("title", config.issueTitle)
|
||||
.appendQueryParameter("body", buildIssueBody(context, config.appLabel, report))
|
||||
.build()
|
||||
|
||||
private fun buildIssueBody(context: Context, appLabel: String, report: String): String {
|
||||
val block = if (report.length > MAX_URL_REPORT_CHARS) {
|
||||
context.getString(R.string.crash_report_body_paste)
|
||||
} else {
|
||||
"```\n$report\n```"
|
||||
}
|
||||
return context.getString(R.string.crash_report_body_template, appLabel, block)
|
||||
}
|
||||
|
||||
/** Keep the prefilled body comfortably under common URL-length ceilings. */
|
||||
private const val MAX_URL_REPORT_CHARS = 6_000
|
||||
@@ -0,0 +1,197 @@
|
||||
package de.jeanlucmakiola.floret.crash
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageInfo
|
||||
import android.os.Build
|
||||
import androidx.core.content.pm.PackageInfoCompat
|
||||
import java.io.File
|
||||
import java.io.PrintWriter
|
||||
import java.io.StringWriter
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Privacy-respecting crash capture, shared across the Floret family. On an
|
||||
* uncaught exception it writes a self-contained report to the app's private
|
||||
* storage and then chains to the platform's default handler, so the process
|
||||
* still dies normally (and the OS shows its own "stopped" dialog). Nothing is
|
||||
* uploaded — the apps hold no `INTERNET` permission. The user submits the report
|
||||
* later, by hand, as an issue (see [CrashReportDialog] / [submitCrashReport]).
|
||||
*
|
||||
* The report is built from a fixed [CrashContext] allowlist — app/Android/device
|
||||
* version, locale, time, and the stack trace — and **nothing else**: no device
|
||||
* identifiers, no account names, no app content, no logcat. The user is always
|
||||
* shown the full text before it leaves the device.
|
||||
*
|
||||
* [install] takes a [CrashConfig] (app label + issue-tracker URLs); it is stored
|
||||
* as [config] so the standalone crash surfaces can read it without the app graph.
|
||||
*/
|
||||
object CrashReporter {
|
||||
|
||||
/** The config from the last [install], or null before the app has installed. */
|
||||
var config: CrashConfig? = null
|
||||
private set
|
||||
|
||||
/**
|
||||
* Install the handler. Call first thing in `Application.onCreate()` so it
|
||||
* also catches crashes during startup. The handler swallows nothing — it
|
||||
* persists, then delegates to the previously-registered handler.
|
||||
*/
|
||||
fun install(context: Context, config: CrashConfig) {
|
||||
this.config = config
|
||||
val appContext = context.applicationContext
|
||||
val label = config.appLabel
|
||||
val previous = Thread.getDefaultUncaughtExceptionHandler()
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
// Capturing must never mask the original crash, so guard every step.
|
||||
runCatching {
|
||||
val now = System.currentTimeMillis()
|
||||
writeReport(appContext, buildCrashReport(CrashContext.from(appContext), throwable, now, label))
|
||||
recordCrashTime(appContext, now)
|
||||
}
|
||||
previous?.uncaughtException(thread, throwable)
|
||||
}
|
||||
}
|
||||
|
||||
/** The persisted report from the last crash, or null if there is none. */
|
||||
fun pendingReport(context: Context): String? {
|
||||
val file = reportFile(context)
|
||||
return if (file.exists()) runCatching { file.readText() }.getOrNull()?.takeIf { it.isNotBlank() } else null
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to surface the report unprompted (on the next launch): a report
|
||||
* exists and the user hasn't already waved this one away. Settings reaches
|
||||
* the report via [pendingReport] regardless, so "Not now" only stops the
|
||||
* auto-prompt — it doesn't discard the report.
|
||||
*/
|
||||
fun shouldPrompt(context: Context): Boolean =
|
||||
reportFile(context).exists() && !dismissedFile(context).exists()
|
||||
|
||||
/** Stop auto-prompting for the current report without discarding it. */
|
||||
fun dismissPrompt(context: Context) {
|
||||
runCatching { dismissedFile(context).apply { parentFile?.mkdirs() }.writeText("") }
|
||||
}
|
||||
|
||||
/** Drop the persisted report once the user has reported it (or from Settings). */
|
||||
fun clearReport(context: Context) {
|
||||
runCatching { reportFile(context).delete() }
|
||||
runCatching { dismissedFile(context).delete() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the app appears to be in a startup crash-loop: at least
|
||||
* [LOOP_THRESHOLD] crashes inside [LOOP_WINDOW_MS]. In that case the main UI
|
||||
* can't be trusted to start, so the caller routes straight to the standalone
|
||||
* report screen instead of re-entering the crashing graph.
|
||||
*/
|
||||
fun isCrashLoop(context: Context): Boolean {
|
||||
val times = readCrashTimes(context)
|
||||
if (times.size < LOOP_THRESHOLD) return false
|
||||
val recent = times.sortedDescending()
|
||||
return recent[0] - recent[LOOP_THRESHOLD - 1] <= LOOP_WINDOW_MS
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the app as having started successfully, resetting the loop counter so
|
||||
* an ordinary single crash much later never trips loop detection. The
|
||||
* pending report itself is kept — only the timing trail is cleared.
|
||||
*/
|
||||
fun markHealthy(context: Context) {
|
||||
runCatching { timesFile(context).delete() }
|
||||
}
|
||||
|
||||
// --- persistence -------------------------------------------------------
|
||||
|
||||
private fun writeReport(context: Context, report: String) {
|
||||
val file = reportFile(context).apply { parentFile?.mkdirs() }
|
||||
file.writeText(report.take(MAX_REPORT_CHARS))
|
||||
// A fresh crash should prompt again, even if the previous one was waved away.
|
||||
runCatching { dismissedFile(context).delete() }
|
||||
}
|
||||
|
||||
private fun recordCrashTime(context: Context, nowMillis: Long) {
|
||||
val kept = (readCrashTimes(context) + nowMillis).takeLast(MAX_TIMES)
|
||||
timesFile(context).apply { parentFile?.mkdirs() }
|
||||
.writeText(kept.joinToString("\n"))
|
||||
}
|
||||
|
||||
private fun readCrashTimes(context: Context): List<Long> {
|
||||
val file = timesFile(context)
|
||||
if (!file.exists()) return emptyList()
|
||||
return runCatching { file.readLines().mapNotNull { it.trim().toLongOrNull() } }.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
private fun crashDir(context: Context) = File(context.filesDir, CRASH_DIR)
|
||||
private fun reportFile(context: Context) = File(crashDir(context), REPORT_FILE)
|
||||
private fun timesFile(context: Context) = File(crashDir(context), TIMES_FILE)
|
||||
private fun dismissedFile(context: Context) = File(crashDir(context), DISMISSED_FILE)
|
||||
|
||||
private const val CRASH_DIR = "crash"
|
||||
private const val REPORT_FILE = "last_crash.txt"
|
||||
private const val TIMES_FILE = "crash_times.txt"
|
||||
private const val DISMISSED_FILE = "dismissed"
|
||||
private const val MAX_TIMES = 5
|
||||
private const val MAX_REPORT_CHARS = 64 * 1024
|
||||
private const val LOOP_THRESHOLD = 2
|
||||
private const val LOOP_WINDOW_MS = 10_000L
|
||||
}
|
||||
|
||||
/**
|
||||
* The allowlist of non-personal facts that go into a crash report. Built from
|
||||
* [Build] and the app's own [PackageInfo]; deliberately holds no identifiers.
|
||||
*/
|
||||
data class CrashContext(
|
||||
val appVersionName: String,
|
||||
val appVersionCode: Long,
|
||||
val sdkInt: Int,
|
||||
val androidRelease: String,
|
||||
val manufacturer: String,
|
||||
val model: String,
|
||||
val locale: String,
|
||||
) {
|
||||
companion object {
|
||||
fun from(context: Context): CrashContext {
|
||||
val pkg = runCatching {
|
||||
context.packageManager.getPackageInfo(context.packageName, 0)
|
||||
}.getOrNull()
|
||||
return CrashContext(
|
||||
appVersionName = pkg?.versionName ?: "?",
|
||||
appVersionCode = pkg?.let { PackageInfoCompat.getLongVersionCode(it) } ?: 0L,
|
||||
sdkInt = Build.VERSION.SDK_INT,
|
||||
androidRelease = Build.VERSION.RELEASE ?: "?",
|
||||
manufacturer = Build.MANUFACTURER ?: "?",
|
||||
model = Build.MODEL ?: "?",
|
||||
locale = Locale.getDefault().toLanguageTag(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a crash report from the [ctx] allowlist, the [throwable]'s full stack
|
||||
* trace, the crash [nowMillis], and the [appLabel] (for the header). Pure (no
|
||||
* Android, no I/O) so it is unit tested. The leading marker doubles as the
|
||||
* file's sanity check in [CrashReporter.pendingReport].
|
||||
*/
|
||||
fun buildCrashReport(ctx: CrashContext, throwable: Throwable, nowMillis: Long, appLabel: String): String {
|
||||
val trace = StringWriter().also { throwable.printStackTrace(PrintWriter(it)) }.toString().trim()
|
||||
val time = runCatching {
|
||||
Instant.ofEpochMilli(nowMillis).atZone(ZoneId.systemDefault()).format(TIME_FORMAT)
|
||||
}.getOrDefault(nowMillis.toString())
|
||||
return buildString {
|
||||
appendLine("$appLabel crash report")
|
||||
appendLine("App version: ${ctx.appVersionName} (${ctx.appVersionCode})")
|
||||
appendLine("Android: ${ctx.androidRelease} (API ${ctx.sdkInt})")
|
||||
appendLine("Device: ${ctx.manufacturer} ${ctx.model}")
|
||||
appendLine("Locale: ${ctx.locale}")
|
||||
appendLine("Time: $time")
|
||||
appendLine()
|
||||
appendLine("Stack trace:")
|
||||
append(trace)
|
||||
}
|
||||
}
|
||||
|
||||
private val TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||
19
core-crash/src/main/res/values/strings.xml
Normal file
19
core-crash/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,19 @@
|
||||
<resources>
|
||||
<!--
|
||||
Generic crash-report UI strings shared by the family. App-agnostic: the
|
||||
app's display name is injected as %1$s from CrashConfig.appLabel, so these
|
||||
read correctly for any app. Issue-tracker URLs are NOT here — they are
|
||||
per-app and come from CrashConfig. Apps may override any of these strings.
|
||||
-->
|
||||
<string name="crash_dialog_title">%1$s crashed</string>
|
||||
<string name="crash_dialog_message">%1$s closed unexpectedly last time. You can help fix it by sending this report as an issue. It stays on your device until you choose to share it, and includes no personal data or app content — only the technical details below.</string>
|
||||
<string name="crash_dialog_report">Report</string>
|
||||
<string name="crash_dialog_dismiss">Not now</string>
|
||||
<string name="crash_report_clip_label">%1$s crash report</string>
|
||||
<string name="crash_report_copied">Report copied to your clipboard</string>
|
||||
<string name="crash_report_open_failed">Couldn\'t open the issue tracker. The report is on your clipboard.</string>
|
||||
<string name="crash_report_body_template">Thanks for reporting a crash in %1$s. Please add anything you remember about what you were doing, then submit.\n\n### What happened\n\n\n### Crash report\n%2$s\n</string>
|
||||
<string name="crash_report_body_paste">_(The report was too long for this link — paste it from your clipboard here.)_</string>
|
||||
<!-- Fallback app label if the dialog is shown before install() (should not happen). -->
|
||||
<string name="crash_app_fallback">The app</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,41 @@
|
||||
package de.jeanlucmakiola.floret.crash
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class BuildCrashReportTest {
|
||||
|
||||
private val ctx = CrashContext(
|
||||
appVersionName = "1.2.3",
|
||||
appVersionCode = 10203,
|
||||
sdkInt = 34,
|
||||
androidRelease = "14",
|
||||
manufacturer = "Acme",
|
||||
model = "Pixel",
|
||||
locale = "en-US",
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `report leads with the app label header`() {
|
||||
val report = buildCrashReport(ctx, RuntimeException("boom"), 0L, "Agendula")
|
||||
assertThat(report.lineSequence().first()).isEqualTo("Agendula crash report")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `report includes the allowlisted facts and the stack trace`() {
|
||||
val report = buildCrashReport(ctx, IllegalStateException("kaboom"), 0L, "Calendula")
|
||||
assertThat(report).contains("App version: 1.2.3 (10203)")
|
||||
assertThat(report).contains("Android: 14 (API 34)")
|
||||
assertThat(report).contains("Device: Acme Pixel")
|
||||
assertThat(report).contains("Locale: en-US")
|
||||
assertThat(report).contains("Stack trace:")
|
||||
assertThat(report).contains("kaboom")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the throwable's message and type appear in the trace`() {
|
||||
val report = buildCrashReport(ctx, IllegalArgumentException("bad arg"), 0L, "App")
|
||||
assertThat(report).contains("IllegalArgumentException")
|
||||
assertThat(report).contains("bad arg")
|
||||
}
|
||||
}
|
||||
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
|
||||
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
import java.time.ZoneId
|
||||
import java.time.chrono.IsoChronology
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.DateTimeFormatterBuilder
|
||||
import java.time.format.FormatStyle
|
||||
import java.util.Locale
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
@@ -26,3 +31,28 @@ fun Instant.formatTime(): String = atSystemZone().format(timeFormatter)
|
||||
/** Date alone for all-day items, otherwise date + time. */
|
||||
fun Instant.formatDateTime(allDay: Boolean): String =
|
||||
if (allDay) formatDate() else "${formatDate()} · ${formatTime()}"
|
||||
|
||||
// The locale's medium date pattern with the year token stripped, e.g. "20 Jun" /
|
||||
// "Jun 20" — derived from the pattern so day/month order still follows the locale.
|
||||
// Built once; locale changes within a running process are rare enough to ignore.
|
||||
private val dateNoYearFormatter: DateTimeFormatter = run {
|
||||
val medium = DateTimeFormatterBuilder.getLocalizedDateTimePattern(
|
||||
FormatStyle.MEDIUM, null, IsoChronology.INSTANCE, Locale.getDefault(),
|
||||
)
|
||||
val noYear = medium.replace(Regex("[\\s.,/'’-]*y+[\\s.,/'’-]*"), " ").trim()
|
||||
DateTimeFormatter.ofPattern(noYear, Locale.getDefault())
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact form for dense list rows: the year is dropped when the date is in the
|
||||
* current year, and the time is dropped when it carries no information (all-day
|
||||
* items, or a midnight time). Examples: "20 Jun", "20 Jun · 14:30", "20 Jun 2027".
|
||||
*/
|
||||
fun Instant.formatDateTimeCompact(allDay: Boolean): String {
|
||||
val zdt = atSystemZone()
|
||||
val datePart =
|
||||
if (zdt.year == LocalDate.now(ZoneId.systemDefault()).year) zdt.format(dateNoYearFormatter)
|
||||
else formatDate()
|
||||
return if (allDay || zdt.toLocalTime() == LocalTime.MIDNIGHT) datePart
|
||||
else "$datePart · ${formatTime()}"
|
||||
}
|
||||
|
||||
@@ -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,9 @@
|
||||
package de.jeanlucmakiola.floret.time
|
||||
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** Epoch milliseconds (as stored by Android content providers) → [Instant]. */
|
||||
fun Long.toKotlinInstantFromEpochMillis(): Instant = Instant.fromEpochMilliseconds(this)
|
||||
|
||||
/** [Instant] → epoch milliseconds, for writing back to a content provider. */
|
||||
fun Instant.toEpochMillis(): Long = toEpochMilliseconds()
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package de.jeanlucmakiola.floret.time
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlin.time.Instant
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class TimeBridgeTest {
|
||||
|
||||
@Test
|
||||
fun `epoch millis round-trips through Instant`() {
|
||||
val original = 1_717_840_800_000L // 2024-06-08T10:00:00Z
|
||||
val instant = original.toKotlinInstantFromEpochMillis()
|
||||
assertThat(instant.toEpochMillis()).isEqualTo(original)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `zero millis maps to Instant epoch`() {
|
||||
assertThat(0L.toKotlinInstantFromEpochMillis()).isEqualTo(Instant.fromEpochMilliseconds(0L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `negative epoch millis is supported`() {
|
||||
val original = -1_000_000L
|
||||
assertThat(original.toKotlinInstantFromEpochMillis().toEpochMillis()).isEqualTo(original)
|
||||
}
|
||||
}
|
||||
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.
|
||||
@@ -1,16 +1,35 @@
|
||||
# Version catalog for floret-kit. Kept in lockstep with the consuming apps'
|
||||
# catalogs (same Kotlin + test stack) until the catalog itself is shared.
|
||||
# catalogs (same AGP/Kotlin/Compose + test stack) until the catalog itself is shared.
|
||||
[versions]
|
||||
agp = "9.2.1"
|
||||
kotlin = "2.3.21"
|
||||
coreKtx = "1.19.0"
|
||||
appcompat = "1.7.1"
|
||||
activityCompose = "1.13.0"
|
||||
composeBom = "2026.05.01"
|
||||
# Material 3 Expressive APIs live in the 1.5 alpha line; pinned to override the BOM.
|
||||
material3 = "1.5.0-alpha21"
|
||||
junit = "6.1.0"
|
||||
junitPlatform = "6.1.0"
|
||||
truth = "1.4.5"
|
||||
|
||||
[libraries]
|
||||
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-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||
androidx-foundation = { group = "androidx.compose.foundation", name = "foundation" }
|
||||
androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" }
|
||||
androidx-animation = { group = "androidx.compose.animation", name = "animation" }
|
||||
androidx-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" }
|
||||
|
||||
junit-jupiter-api = { group = "org.junit.jupiter", name = "junit-jupiter-api", version.ref = "junit" }
|
||||
junit-jupiter-engine = { group = "org.junit.jupiter", name = "junit-jupiter-engine", version.ref = "junit" }
|
||||
junit-platform-launcher = { group = "org.junit.platform", name = "junit-platform-launcher", version.ref = "junitPlatform" }
|
||||
truth = { group = "com.google.truth", name = "truth", version.ref = "truth" }
|
||||
|
||||
[plugins]
|
||||
android-library = { id = "com.android.library", version.ref = "agp" }
|
||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
||||
|
||||
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))
|
||||
}
|
||||
@@ -11,9 +11,12 @@ pluginManagement {
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
plugins {
|
||||
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
|
||||
}
|
||||
|
||||
// NB: deliberately NO Gradle Java-toolchain auto-download resolver plugin here.
|
||||
// Consuming apps build this as an included build, and Calendula publishes via
|
||||
// official F-Droid reproducible builds whose offline source scanner rejects any
|
||||
// toolchain resolver that can fetch a JDK at build time. Modules set jvmTarget
|
||||
// directly and must not add a Java toolchain block that needs such a resolver.
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
@@ -29,3 +32,8 @@ dependencyResolutionManagement {
|
||||
rootProject.name = "floret-kit"
|
||||
|
||||
include(":core-time")
|
||||
include(":core-reminders")
|
||||
include(":core-locale")
|
||||
include(":core-crash")
|
||||
include(":identity")
|
||||
include(":components")
|
||||
|
||||
Reference in New Issue
Block a user