Compare commits
9 Commits
45ca3243fe
...
fix/all-da
| Author | SHA1 | Date | |
|---|---|---|---|
| 396e538903 | |||
| 75b90d1b3b | |||
| b4954713ff | |||
| df8bdbaf73 | |||
| 9fe655a33b | |||
| 3aa4ceada5 | |||
| a50878a7ac | |||
| a1884fc5fa | |||
| 60dd4eea71 |
27
CHANGELOG.md
27
CHANGELOG.md
@@ -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.
|
||||
|
||||
@@ -10,5 +10,5 @@ plugins {
|
||||
|
||||
subprojects {
|
||||
group = "de.jeanlucmakiola.floret"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()}"
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user