10 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
11 changed files with 284 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
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
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-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

@@ -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-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`. |
| `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`. |

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

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