4 Commits

Author SHA1 Message Date
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
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
8 changed files with 214 additions and 8 deletions

View File

@@ -12,6 +12,40 @@ are no binary releases; "version" tracks the shared `version` in the root build
### Added ### 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
### Added
- **`core-time`** (JVM library) — pure-Kotlin date/time helpers, no Android and - **`core-time`** (JVM library) — pure-Kotlin date/time helpers, no Android and
no third-party dependencies: no third-party dependencies:
- `DayWindow.today()` — local-day boundaries for smart-list logic. - `DayWindow.today()` — local-day boundaries for smart-list logic.
@@ -46,7 +80,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). `largeTopBar = false` for a pinned single-line bar (the picker variant).
- `FullScreenPicker` / `OptionPicker` — the full-screen single-select picker, - `FullScreenPicker` / `OptionPicker` — the full-screen single-select picker,
now over a pinned single-line bar (a picker is a short list, so no tall 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 - `ReorderableColumn` — dependency-free drag-to-reorder for the short, fixed
grouped-card Settings lists; pairs with `GroupedRow(gapBelow = false)`, which grouped-card Settings lists; pairs with `GroupedRow(gapBelow = false)`, which
hands the column uniform control of row spacing. 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-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

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

View File

@@ -1,8 +1,11 @@
package de.jeanlucmakiola.floret.components package de.jeanlucmakiola.floret.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape 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.material.icons.filled.ArrowDropDown
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme 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.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable 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. */ /** Tonal dropdown trigger + menu shared by the custom reminder/recurrence steps and pickers. */
@Composable @Composable
fun DialogUnitDropdown( fun DialogUnitDropdown(

View File

@@ -2,6 +2,7 @@ package de.jeanlucmakiola.floret.components
import android.view.WindowManager import android.view.WindowManager
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.RowScope
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.Check
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -25,6 +26,7 @@ fun FullScreenPicker(
title: String, title: String,
onDismiss: () -> Unit, onDismiss: () -> Unit,
predictiveBack: Boolean = false, predictiveBack: Boolean = false,
actions: @Composable RowScope.() -> Unit = {},
content: @Composable ColumnScope.() -> Unit, content: @Composable ColumnScope.() -> Unit,
) { ) {
Dialog( Dialog(
@@ -48,6 +50,7 @@ fun FullScreenPicker(
onBack = onDismiss, onBack = onDismiss,
largeTopBar = false, largeTopBar = false,
predictiveBack = predictiveBack, predictiveBack = predictiveBack,
actions = actions,
content = content, content = content,
) )
} }

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()
}
}