Merge branch 'feat/locale-date-formatting' into main

This commit is contained in:
2026-07-17 14:54:38 +02:00
5 changed files with 121 additions and 6 deletions

View File

@@ -10,6 +10,38 @@ are no binary releases; "version" tracks the shared `version` in the root build
## [Unreleased] ## [Unreleased]
### Added
- **`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 ## [0.1.0] - 2026-07-07
### Added ### Added

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-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-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`. | | `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. | | `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`. | | `components` | Android | Shared Compose vocabulary + recipes: `GroupedSurface`/`GroupedRow`, `InlineTextField`, `OptionCard`, `CollapsingScaffold`, `OptionPicker`, `ReorderableColumn`, `DebugRibbon`, `pastelize()`, `DialogControls`, `OnboardingScaffold`, `OptionalFormSection`, `AboutCard`, `LanguagePickerRow`. |

View File

@@ -1,10 +1,20 @@
// core-locale — per-app language plumbing shared across the family: read the // core-locale — everything "what language are we in", shared across the family:
// shipped languages from res/xml/locales_config.xml, get/set the applied // read the shipped languages from res/xml/locales_config.xml, get/set the applied
// language via AppCompatDelegate, and render each language's autonym. App- // language via AppCompatDelegate, render each language's autonym, observe the
// agnostic — the consuming app passes its own locales_config resource id; no // current locale from Compose, and format dates in that locale's own field order.
// Compose, no DataStore. // 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 { 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.android.library)
alias(libs.plugins.kotlin.compose)
} }
android { android {
@@ -20,6 +30,10 @@ android {
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17
} }
buildFeatures {
compose = true
}
testOptions { testOptions {
unitTests { unitTests {
all { it.useJUnitPlatform() } all { it.useJUnitPlatform() }
@@ -39,6 +53,16 @@ dependencies {
// annotation (XmlRes) transitives. // annotation (XmlRes) transitives.
implementation(libs.androidx.appcompat) 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) testImplementation(libs.junit.jupiter.api)
testRuntimeOnly(libs.junit.jupiter.engine) testRuntimeOnly(libs.junit.jupiter.engine)
testRuntimeOnly(libs.junit.platform.launcher) 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()
}
}