9 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
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
7 changed files with 163 additions and 9 deletions

View File

@@ -6,12 +6,31 @@ 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.1`). Entries are grouped by module.
## [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
@@ -46,6 +65,12 @@ are no binary releases; "version" tracks the shared `version` in the root build
### 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.

View File

@@ -10,5 +10,5 @@ plugins {
subprojects {
group = "de.jeanlucmakiola.floret"
version = "0.2.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
* transient messages; [predictiveBack] (off by default) wires the gesture
* 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)
@Composable
@@ -55,6 +65,7 @@ fun CollapsingScaffold(
modifier: Modifier = Modifier,
largeTopBar: Boolean = true,
predictiveBack: Boolean = false,
scrollable: Boolean = true,
actions: @Composable RowScope.() -> Unit = {},
snackbarHost: @Composable () -> Unit = {},
content: @Composable ColumnScope.() -> Unit,
@@ -112,8 +123,19 @@ fun CollapsingScaffold(
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface)
.imePadding()
.verticalScroll(rememberScrollState())
.padding(top = 8.dp, bottom = 24.dp),
.then(
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,
)
}

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
* collapsing header would only be empty space to scroll past. [content] places
* 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
fun FullScreenPicker(
title: String,
onDismiss: () -> Unit,
predictiveBack: Boolean = false,
scrollable: Boolean = true,
actions: @Composable RowScope.() -> Unit = {},
content: @Composable ColumnScope.() -> Unit,
) {
@@ -50,6 +55,7 @@ fun FullScreenPicker(
onBack = onDismiss,
largeTopBar = false,
predictiveBack = predictiveBack,
scrollable = scrollable,
actions = actions,
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
private fun SelectedCheck() {
fun SelectedCheck() {
Icon(
imageVector = Icons.Rounded.Check,
contentDescription = null,

View File

@@ -3,6 +3,7 @@ package de.jeanlucmakiola.floret.time
import java.time.LocalDate
import java.time.LocalTime
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.chrono.IsoChronology
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeFormatterBuilder
@@ -22,15 +23,28 @@ private fun Instant.atSystemZone() =
java.time.Instant.ofEpochMilli(toEpochMilliseconds())
.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". */
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". */
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()}"
if (allDay) formatDate(allDay = true) 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.
@@ -49,10 +63,10 @@ private val dateNoYearFormatter: DateTimeFormatter = run {
* 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 zdt = atDisplayZone(allDay)
val datePart =
if (zdt.year == LocalDate.now(ZoneId.systemDefault()).year) zdt.format(dateNoYearFormatter)
else formatDate()
else formatDate(allDay)
return if (allDay || zdt.toLocalTime() == LocalTime.MIDNIGHT) datePart
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-reminders` | JVM | Agendula (and Calendula's pending branch) |
| `core-locale` | Android | Agendula, Calendula |
| `core-crash` | Android | Agendula |
| `identity` | Android | Agendula |
| `components` | Android | Agendula |