11 Commits

Author SHA1 Message Date
396e538903 fix(core-time): render all-day values in UTC, not the device zone
All-day items are date-only in iCalendar and are stored as UTC midnight of
their calendar date — the convention the tasks and calendar providers read
back (OpenTasks resolves them through DateTime.toAllDay(), which discards the
time and resolves the remaining date against UTC).

formatDate/formatDateTime/formatDateTimeCompact resolved every instant against
ZoneId.systemDefault(), so an all-day value rendered as the *previous* day for
anyone west of UTC: a task stored as 2026-07-20T00:00Z showed as 19 Jul in New
York. East of UTC it happened to land on the right day, which is why this went
unnoticed.

formatDate now takes an allDay flag (defaulting to false, so existing call
sites are unaffected) and all-day values resolve against UTC.

Callers must pass allDay at the call site — agendula and calendula both have
sites that need updating.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 22:09:28 +02:00
75b90d1b3b Merge pull request 'feat(components): expose SelectedCheck as a family primitive' (#2) from feat/selected-check-primitive into main 2026-07-20 13:27:22 +00:00
b4954713ff feat(components): expose SelectedCheck as a family primitive
The trailing check that marks a chosen row in a full-screen picker was
private to OptionPicker, so every consumer hand-rolled its own copy.
Calendula alone carried five, and they had drifted: the kit drew
Icons.Rounded.Check, all five app copies drew Icons.Default.Check.

Make it public with KDoc so pickers share one selection affordance
instead of re-deriving it. Rounded wins, as the kit's existing choice.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 15:17:29 +02:00
df8bdbaf73 release: floret-kit 0.2.1
Adds components' `scrollable` opt-out on CollapsingScaffold (and its
FullScreenPicker passthrough), so a picker with more options than an
eagerly composed column can carry can drive its own LazyColumn.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 15:36:44 +02:00
9fe655a33b Merge branch 'feat/scrollable-scaffold' into main 2026-07-17 15:36:21 +02:00
3aa4ceada5 feat(components): let picker content own its scrolling
CollapsingScaffold always wrapped its content in a verticalScroll Column,
which composes every row up front. That suits the short lists these
surfaces normally hold, but it rules out a LazyColumn entirely: nesting a
same-axis scrollable inside it throws, so a caller with a few hundred
options had no way to render them.

Add `scrollable` (default true, so every existing caller is unchanged).
When false the content gets a plain column to fill and brings its own
scrolling container, keeping the insets, IME padding and top-bar collapse
as they were. FullScreenPicker forwards the flag for pickers that need it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 15:31:18 +02:00
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
12 changed files with 368 additions and 15 deletions

View File

@@ -6,14 +6,71 @@ 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 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 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 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.1`). Entries are grouped by module.
## [Unreleased] ## [Unreleased]
## [0.2.1] - 2026-07-17
### Added
- **`components`** — `CollapsingScaffold` takes a `scrollable` flag (default
`true`, so every existing caller is unchanged). Set it to `false` when the
content brings its own scrolling container: the scaffold's own
`verticalScroll` composes each row up front and can't host a `LazyColumn`
(nesting a same-axis scrollable throws), which ruled out any surface with more
options than fit an eager pass. Opting out hands the content a plain column and
leaves the insets, IME padding and top-bar collapse as they were.
`FullScreenPicker` forwards the flag. Consumed by Calendula's ~600-entry
time-zone picker from 2.16.0.
## [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 ## [0.1.0] - 2026-07-07
### Added ### 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 - **`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.

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.1"
} }

View File

@@ -46,6 +46,16 @@ import de.jeanlucmakiola.floret.identity.predictiveBack
* unchanged: [actions] adds trailing app-bar items; [snackbarHost] hosts * unchanged: [actions] adds trailing app-bar items; [snackbarHost] hosts
* transient messages; [predictiveBack] (off by default) wires the gesture * transient messages; [predictiveBack] (off by default) wires the gesture
* preview to [onBack] for full-screen surfaces that want it. * preview to [onBack] for full-screen surfaces that want it.
*
* Set [scrollable] to false when [content] brings its own scrolling container.
* The default column scrolls as a whole, which composes every row up front —
* fine for the short lists these surfaces normally hold, but a list long enough
* to need a [androidx.compose.foundation.lazy.LazyColumn] can neither nest one
* inside it (same-axis scrolling throws) nor afford the eager pass. Opting out
* hands [content] a plain, unscrolled column to fill instead; the insets, IME
* padding and bar behaviour are unchanged, so a lazy list simply takes over the
* scrolling and gets the top bar's collapse for free through the shared
* nested-scroll connection.
*/ */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -55,6 +65,7 @@ fun CollapsingScaffold(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
largeTopBar: Boolean = true, largeTopBar: Boolean = true,
predictiveBack: Boolean = false, predictiveBack: Boolean = false,
scrollable: Boolean = true,
actions: @Composable RowScope.() -> Unit = {}, actions: @Composable RowScope.() -> Unit = {},
snackbarHost: @Composable () -> Unit = {}, snackbarHost: @Composable () -> Unit = {},
content: @Composable ColumnScope.() -> Unit, content: @Composable ColumnScope.() -> Unit,
@@ -112,8 +123,19 @@ fun CollapsingScaffold(
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.surface) .background(MaterialTheme.colorScheme.surface)
.imePadding() .imePadding()
.verticalScroll(rememberScrollState()) .then(
.padding(top = 8.dp, bottom = 24.dp), if (scrollable) {
Modifier
.verticalScroll(rememberScrollState())
.padding(top = 8.dp, bottom = 24.dp)
} else {
// The content scrolls itself, so it also owns the edge
// spacing — a lazy list wants it as contentPadding, or
// the first and last rows clip instead of scrolling
// under the bars.
Modifier
},
),
content = content, content = content,
) )
} }

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

@@ -20,12 +20,17 @@ import androidx.compose.ui.window.DialogWindowProvider
* width without a tall header — it's a short selection list, so the large * 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 * collapsing header would only be empty space to scroll past. [content] places
* the connected grouped rows; selecting one calls [onDismiss]. * the connected grouped rows; selecting one calls [onDismiss].
*
* [scrollable] forwards to [CollapsingScaffold]: leave it on unless [content]
* scrolls itself, which a picker only needs for an option list long enough to
* warrant a lazy container.
*/ */
@Composable @Composable
fun FullScreenPicker( fun FullScreenPicker(
title: String, title: String,
onDismiss: () -> Unit, onDismiss: () -> Unit,
predictiveBack: Boolean = false, predictiveBack: Boolean = false,
scrollable: Boolean = true,
actions: @Composable RowScope.() -> Unit = {}, actions: @Composable RowScope.() -> Unit = {},
content: @Composable ColumnScope.() -> Unit, content: @Composable ColumnScope.() -> Unit,
) { ) {
@@ -50,6 +55,7 @@ fun FullScreenPicker(
onBack = onDismiss, onBack = onDismiss,
largeTopBar = false, largeTopBar = false,
predictiveBack = predictiveBack, predictiveBack = predictiveBack,
scrollable = scrollable,
actions = actions, actions = actions,
content = content, content = content,
) )
@@ -96,8 +102,18 @@ fun <T> OptionPicker(
} }
} }
/**
* The trailing check that marks the chosen row in a full-screen picker — the
* family's single selection affordance, so every picker (option, reminder,
* agenda range, timezone, calendar, recurrence) marks its selection the same
* way. Pair it with `GroupedRow(selected = true)`: the tonal highlight carries
* the state at a glance, the check confirms it unambiguously.
*
* Purely decorative — the row itself owns the semantics, so it takes no
* content description.
*/
@Composable @Composable
private fun SelectedCheck() { fun SelectedCheck() {
Icon( Icon(
imageVector = Icons.Rounded.Check, imageVector = Icons.Rounded.Check,
contentDescription = null, contentDescription = null,

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

View File

@@ -3,6 +3,7 @@ package de.jeanlucmakiola.floret.time
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalTime import java.time.LocalTime
import java.time.ZoneId import java.time.ZoneId
import java.time.ZoneOffset
import java.time.chrono.IsoChronology import java.time.chrono.IsoChronology
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.time.format.DateTimeFormatterBuilder import java.time.format.DateTimeFormatterBuilder
@@ -22,15 +23,28 @@ private fun Instant.atSystemZone() =
java.time.Instant.ofEpochMilli(toEpochMilliseconds()) java.time.Instant.ofEpochMilli(toEpochMilliseconds())
.atZone(ZoneId.systemDefault()) .atZone(ZoneId.systemDefault())
/**
* The zone an instant should be read in to recover the date it denotes.
*
* All-day items are date-only in iCalendar and are stored as **UTC midnight** of
* their calendar date (the convention the tasks/calendar providers read back).
* Rendering one in the device zone shows the previous day for anyone west of
* UTC — so all-day values resolve against UTC, timed values against the device.
*/
private fun Instant.atDisplayZone(allDay: Boolean) =
if (allDay) java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(ZoneOffset.UTC)
else atSystemZone()
/** Medium localized date, e.g. "18 Jun 2026". */ /** Medium localized date, e.g. "18 Jun 2026". */
fun Instant.formatDate(): String = atSystemZone().format(dateFormatter) fun Instant.formatDate(allDay: Boolean = false): String =
atDisplayZone(allDay).format(dateFormatter)
/** Short localized time, e.g. "14:30". */ /** Short localized time, e.g. "14:30". */
fun Instant.formatTime(): String = atSystemZone().format(timeFormatter) fun Instant.formatTime(): String = atSystemZone().format(timeFormatter)
/** Date alone for all-day items, otherwise date + time. */ /** Date alone for all-day items, otherwise date + time. */
fun Instant.formatDateTime(allDay: Boolean): String = fun Instant.formatDateTime(allDay: Boolean): String =
if (allDay) formatDate() else "${formatDate()} · ${formatTime()}" if (allDay) formatDate(allDay = true) else "${formatDate()} · ${formatTime()}"
// The locale's medium date pattern with the year token stripped, e.g. "20 Jun" / // 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. // "Jun 20" — derived from the pattern so day/month order still follows the locale.
@@ -49,10 +63,10 @@ private val dateNoYearFormatter: DateTimeFormatter = run {
* items, or a midnight time). Examples: "20 Jun", "20 Jun · 14:30", "20 Jun 2027". * items, or a midnight time). Examples: "20 Jun", "20 Jun · 14:30", "20 Jun 2027".
*/ */
fun Instant.formatDateTimeCompact(allDay: Boolean): String { fun Instant.formatDateTimeCompact(allDay: Boolean): String {
val zdt = atSystemZone() val zdt = atDisplayZone(allDay)
val datePart = val datePart =
if (zdt.year == LocalDate.now(ZoneId.systemDefault()).year) zdt.format(dateNoYearFormatter) if (zdt.year == LocalDate.now(ZoneId.systemDefault()).year) zdt.format(dateNoYearFormatter)
else formatDate() else formatDate(allDay)
return if (allDay || zdt.toLocalTime() == LocalTime.MIDNIGHT) datePart return if (allDay || zdt.toLocalTime() == LocalTime.MIDNIGHT) datePart
else "$datePart · ${formatTime()}" else "$datePart · ${formatTime()}"
} }

View File

@@ -0,0 +1,76 @@
package de.jeanlucmakiola.floret.time
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import java.time.LocalDate
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.TimeZone
import kotlin.time.Instant
class DateTimeFormatTest {
private val originalZone: TimeZone = TimeZone.getDefault()
@AfterEach
fun restoreZone() = TimeZone.setDefault(originalZone)
private val july20: LocalDate = LocalDate.of(2026, 7, 20)
/** The all-day storage form: UTC midnight of the calendar date. */
private val allDayJuly20: Instant = Instant.fromEpochMilliseconds(
july20.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli(),
)
/** What the locale renders for a given date — so the test doesn't pin a format. */
private fun medium(date: LocalDate): String =
date.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM))
@Test
fun `an all-day date reads the same whatever the device zone`() {
TimeZone.setDefault(TimeZone.getTimeZone("America/New_York")) // UTC-4 in July
assertThat(allDayJuly20.formatDate(allDay = true)).isEqualTo(medium(july20))
TimeZone.setDefault(TimeZone.getTimeZone("Europe/Berlin")) // UTC+2 in July
assertThat(allDayJuly20.formatDate(allDay = true)).isEqualTo(medium(july20))
TimeZone.setDefault(TimeZone.getTimeZone("Pacific/Auckland")) // UTC+12
assertThat(allDayJuly20.formatDate(allDay = true)).isEqualTo(medium(july20))
}
@Test
fun `reading an all-day value as timed slips a day west of UTC`() {
// Guards the regression directly: this is what the all-day flag prevents.
TimeZone.setDefault(TimeZone.getTimeZone("America/New_York"))
assertThat(allDayJuly20.formatDate()).isEqualTo(medium(july20.minusDays(1)))
assertThat(allDayJuly20.formatDate(allDay = true)).isEqualTo(medium(july20))
}
@Test
fun `timed values still render in the device zone`() {
// 2026-07-20T23:30Z — the next day already, in Auckland.
val lateUtc = Instant.fromEpochMilliseconds(
july20.atTime(23, 30).atZone(ZoneOffset.UTC).toInstant().toEpochMilli(),
)
TimeZone.setDefault(TimeZone.getTimeZone("Pacific/Auckland"))
assertThat(lateUtc.formatDate()).isEqualTo(medium(july20.plusDays(1)))
}
@Test
fun `formatDateTime drops the time for all-day and keeps it otherwise`() {
TimeZone.setDefault(TimeZone.getTimeZone("Europe/Berlin"))
assertThat(allDayJuly20.formatDateTime(allDay = true)).isEqualTo(medium(july20))
assertThat(allDayJuly20.formatDateTime(allDay = false)).contains("·")
}
@Test
fun `compact form is zone-correct for all-day too`() {
TimeZone.setDefault(TimeZone.getTimeZone("America/New_York"))
val compact = allDayJuly20.formatDateTimeCompact(allDay = true)
// No time part, and the day must not have slipped to the 19th.
assertThat(compact).doesNotContain("·")
assertThat(compact).contains("20")
}
}

View File

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