7 Commits

Author SHA1 Message Date
a50878a7ac release: floret-kit 0.2.0
Adds core-locale's formatting half (currentLocale, localizedDateFormatter) and
components' CustomAmountEditor. The root version was already 0.2.0 — 2124227
bumped it without a changelog entry, so this cut backfills that alongside
core-locale's AppLanguage, which shipped in 0.1.0 undocumented.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 14:56:18 +02:00
a1884fc5fa Merge branch 'feat/locale-date-formatting' into main 2026-07-17 14:54:38 +02:00
45ca3243fe core-locale: locale-aware date formatting (currentLocale, localizedDateFormatter)
Both functions lived in Calendula's ui/common/LocaleSupport.kt. Neither is
calendar-specific — "lay this date out the way this language writes dates" is
family plumbing — so they move here, where Agendula can reach them too.

localizedDateFormatter(locale, skeleton) takes a field list ("LLLLy", "dMMMy")
and lets the locale arrange it, which is the only way to render a date correctly
in a language you didn't hand-write a template for. Dropping a field from the
skeleton drops it from the output, so callers ask for "no year" by omitting it
rather than post-processing a longer pattern.

currentLocale() reads the display locale from LocalConfiguration. This module's
own AppLanguage.apply() changes the language inside a running process, so a
locale captured once goes stale; keying on the configuration makes the UI follow
the switch.

core-locale gains Compose (runtime + ui only) for currentLocale(). Its build file
previously declared itself Compose-free, but the alternative — parking the hook
in a Compose module that depends back on this one — splits one concern across two
modules to honour a comment. core-crash is the precedent for a core-* module
carrying Compose.

Leaves core-time's Instant.formatDate() family alone despite the overlap: it is
unused by Calendula but visible to Agendula, so reconciling it is that app's
call, not a drive-by here. Noted in the changelog.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 09:47:36 +02:00
60dd4eea71 Merge pull request 'feat(components): add CustomAmountEditor, bump to 0.2.0' (#1) from feat/custom-snooze-duration into main
Reviewed-on: #1
2026-07-12 10:31:02 +00:00
2124227a7f feat(components): add CustomAmountEditor, bump to 0.2.0
Extract the shared presets-plus-custom editor card — tonal surface with the
connected shape, an optional single-choice unit toggle, and an amount field
with a live preview + confirm — used by the reminder, agenda-range and snooze
pickers into one reusable, presentational component.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 18:01:42 +02:00
78c5fd1fd4 release: floret-kit 0.1.0
Finalize the 0.1.0 CHANGELOG entry (first tagged release). Consumed from
source via git submodule; tagging gives apps a stable, traceable pin.

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 14:23:07 +02:00
9 changed files with 227 additions and 9 deletions

View File

@@ -6,12 +6,57 @@ All notable changes to floret-kit are documented here. The format follows
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.
(currently `0.2.0`). Entries are grouped by module.
## [Unreleased]
## [0.2.0] - 2026-07-17
### Added
- **`components`** — `CustomAmountEditor`, the amount + unit editor behind an
"Custom…" picker option (Calendula's custom snooze duration). Consumed by
Calendula since 2.15.0.
- **`core-locale`** — the locale-aware formatting half of the module, so apps
stop hand-rolling `"$day. $month $year"` templates that bake one language's
field order (and its separators, and its ordinal dot) into every language they
ship:
- `localizedDateFormatter(locale, skeleton)` — a `DateTimeFormatter` for a
field skeleton ("LLLLy", "dMMMy", "EEEdMMM") laid out in the locale's own
order, via Android's best-pattern matching. Drop a field from the skeleton to
drop it from the output — the locale still supplies the arrangement.
- `currentLocale()` — the display locale, observed from `LocalConfiguration`.
Since `AppLanguage.apply()` changes the language inside a running process, a
locale captured once goes stale; this keys recomposition to the change.
### Changed
- **`core-locale`** now carries Compose (runtime + ui only) for `currentLocale()`.
The module previously declared itself Compose-free; applying a language
mid-process is its own API, so the hook that makes the UI notice belongs beside
it rather than in a Compose module depending back on this one. `core-crash` is
the precedent for a `core-*` module carrying Compose.
### Known gaps
- `core-time`'s `Instant.formatDate()` family now overlaps `core-locale`. Its
private `dateNoYearFormatter` strips the year by regex-ing the pattern string,
which `localizedDateFormatter(locale, "dMMM")` does properly; and its
formatters resolve `Locale.getDefault()` once at class-init ("locale changes
within a running process are rare enough to ignore"), which `AppLanguage.apply()`
makes untrue. Left alone for now — it is unused by Calendula but Agendula-visible.
Reconcile when Agendula next re-pins.
## [0.1.0] - 2026-07-07
### Added
- **`core-locale`** (Android library) — 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` resource id; the system-default choice
is `null`. _(Shipped in 0.1.0 but omitted from this changelog at the time;
recorded here retroactively.)_
- **`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.
@@ -46,7 +91,9 @@ are no binary releases; "version" tracks the shared `version` in the root build
`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).
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.

View File

@@ -36,7 +36,7 @@ See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the full consumption mode
|--------|------|---------------|
| `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-locale` | Android | Everything "what language are we in". 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. Plus `currentLocale()` (observes the applied language from Compose) and `localizedDateFormatter(locale, skeleton)` (lays a field skeleton out in the locale's own order). The app passes its own `locales_config`. |
| `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`. |

View File

@@ -10,5 +10,5 @@ plugins {
subprojects {
group = "de.jeanlucmakiola.floret"
version = "0.1.0"
version = "0.2.0"
}

View File

@@ -1,8 +1,11 @@
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
@@ -10,8 +13,12 @@ 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
@@ -57,6 +64,83 @@ fun DialogAmountField(
}
}
/**
* 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(

View File

@@ -2,6 +2,7 @@ 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
@@ -25,6 +26,7 @@ fun FullScreenPicker(
title: String,
onDismiss: () -> Unit,
predictiveBack: Boolean = false,
actions: @Composable RowScope.() -> Unit = {},
content: @Composable ColumnScope.() -> Unit,
) {
Dialog(
@@ -48,6 +50,7 @@ fun FullScreenPicker(
onBack = onDismiss,
largeTopBar = false,
predictiveBack = predictiveBack,
actions = actions,
content = content,
)
}

View File

@@ -1,10 +1,20 @@
// 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.
// core-locale — everything "what language are we in", shared across the family:
// read the shipped languages from res/xml/locales_config.xml, get/set the applied
// language via AppCompatDelegate, render each language's autonym, observe the
// current locale from Compose, and format dates in that locale's own field order.
// App-agnostic — the consuming app passes its own locales_config resource id; no
// DataStore.
//
// Compose is here for currentLocale() alone: applying a language mid-process is
// this module's own API, so the hook that makes the UI notice belongs beside it
// rather than in a Compose module that would have to depend back on this one.
// (core-crash is the precedent for a core-* module carrying Compose.) Deliberately
// lean: runtime + ui only, no material3, no foundation.
plugins {
// AGP 9.x provides built-in Kotlin compilation, so only the Compose plugin is
// applied alongside the Android library plugin.
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.compose)
}
android {
@@ -20,6 +30,10 @@ android {
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
compose = true
}
testOptions {
unitTests {
all { it.useJUnitPlatform() }
@@ -39,6 +53,16 @@ dependencies {
// annotation (XmlRes) transitives.
implementation(libs.androidx.appcompat)
// ConfigurationCompat for currentLocale(). Reachable transitively via
// appcompat, but declared because this module uses it directly.
implementation(libs.androidx.core.ktx)
// currentLocale() only needs the runtime (@Composable, remember) and ui
// (LocalConfiguration) — api() so consumers can use the returned Locale in
// their own composables without re-declaring Compose themselves.
api(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
testImplementation(libs.junit.jupiter.api)
testRuntimeOnly(libs.junit.jupiter.engine)
testRuntimeOnly(libs.junit.platform.launcher)

View File

@@ -0,0 +1,33 @@
package de.jeanlucmakiola.floret.locale
import java.time.format.DateTimeFormatter
import java.util.Locale
/**
* A [DateTimeFormatter] for [skeleton]'s fields laid out in [locale]'s own order,
* via Android's best-pattern matching.
*
* A skeleton lists the *fields* you want, not their arrangement: "dMMMy" asks for
* day, abbreviated month and year, and the locale decides that en-US renders
* "Jul 17, 2026" while de-DE renders "17. Juli 2026". Hand-rolled templates like
* `"$day. $month $year"` can't do this — they bake one locale's order (and its
* separators, and its ordinal dot) into every language the app ships.
*
* Drop a field to drop it from the output: "dMMM" is the same date without the
* year, still in the locale's order. Prefer that over post-processing a longer
* pattern, which cannot know which separators belonged to the removed field.
*
* Common skeletons: "LLLLy" (standalone month + year), "dMMMy" (day, month, year),
* "EEEdMMM" (weekday too). [locale] should come from [currentLocale] in Compose,
* so formatting follows an in-process language change.
*
* NB: this delegates to `android.text.format.DateFormat`, so it needs a real
* Android runtime — under unit tests with `isReturnDefaultValues` it yields no
* pattern. Test the skeleton *choice* in your own code; the layout itself is ICU's
* job and is not worth mocking.
*/
fun localizedDateFormatter(locale: Locale, skeleton: String): DateTimeFormatter =
DateTimeFormatter.ofPattern(
android.text.format.DateFormat.getBestDateTimePattern(locale, skeleton),
locale,
)

View File

@@ -0,0 +1,26 @@
package de.jeanlucmakiola.floret.locale
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalConfiguration
import androidx.core.os.ConfigurationCompat
import java.util.Locale
/**
* The current display [Locale], read observably from [LocalConfiguration].
*
* Use this rather than [Locale.getDefault] anywhere a composable formats a date,
* a name or a number. [AppLanguage.apply] changes the language inside a running
* process, so a locale captured once — in a `val`, a top-level formatter, or a
* `remember {}` with no key — goes stale and keeps rendering the old language.
* Reading through [LocalConfiguration] keys recomposition to the configuration,
* so the UI follows the switch. (Android lint flags the alternative as
* NonObservableLocale.)
*/
@Composable
fun currentLocale(): Locale {
val configuration = LocalConfiguration.current
return remember(configuration) {
ConfigurationCompat.getLocales(configuration).get(0) ?: Locale.getDefault()
}
}

View File

@@ -13,6 +13,7 @@ divergent or app-specific code where it lives.
|---|---|---|
| `core-time` | JVM | Agendula (and Calendula's pending branch) |
| `core-reminders` | JVM | Agendula (and Calendula's pending branch) |
| `core-locale` | Android | Agendula, Calendula |
| `core-crash` | Android | Agendula |
| `identity` | Android | Agendula |
| `components` | Android | Agendula |