Compare commits

...

9 Commits

Author SHA1 Message Date
86f44805d1 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	README.md
2026-06-28 19:20:30 +02:00
09d3f9b934 components: add AboutCard + LanguagePickerRow settings recipes
AboutCard — logo slot + app name/author, a row of outlined link buttons and an
optional tonal highlight link (each opens its URL). LanguagePickerRow — a
GroupedRow that opens the full-screen language picker, backed by core-locale's
AppLanguage. Both parameterized so each app keeps its own strings, logo and row
icon. Adds the components -> core-locale dependency.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:24:17 +02:00
24ada183e2 components: add OptionalFormSection (revealable form section)
The shared wrapper for optional edit-form fields behind a "More fields" toggle:
AnimatedVisibility with the family's expand+fade motion over a full-width Column.
Adds the first intra-kit dependency (components -> identity) for the shared
transitions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:17:57 +02:00
1e36c229ca components: add DialogControls (DialogAmountField + DialogUnitDropdown)
The tonal 3-digit amount input and unit dropdown shared by the custom
reminder/recurrence steps — the family's InlineTextField over a tonal surface,
matching the card/grouped-row language. Drawn from Calendula.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:15:08 +02:00
ea96e970c7 identity: add the shared content transitions
expandEnter/collapseExit (revealable sections), itemEnter (resolved content),
animateItemMotion (lazy-list relayout) and fadeThrough — all driven by the M3
Expressive motion scheme and honouring rememberReduceMotion. Drawn from
Calendula's motion helpers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:13:06 +02:00
2085b82a5e identity: add the shared predictive-back peek (Modifier.predictiveBack)
The standard Android back-gesture preview — the surface scales to ~90%, rounds
its corners and shifts toward the swiped edge as the gesture is dragged, then
commits or springs back. Plus rememberReduceMotion() to honour the system
"remove animations" setting. Drawn from Calendula's CalendarTransitions so both
apps preview back the same way; adds the activity-compose dependency.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 14:31:11 +02:00
10e0ca0b06 components: add the shared onboarding scaffold recipe
OnboardingScaffold (scrollable hero + body, CTAs pinned to the bottom),
BenefitRow and the OnboardingSpace scale — identical across Agendula and
Calendula. The hero is a slot, so each app keeps its own mark.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 14:16:48 +02:00
4eb9809993 core-locale: shared per-app language plumbing (AppLanguage)
Android-library module, drawn from the byte-identical AppLanguage in Agendula
and Calendula: read the shipped languages from res/xml/locales_config.xml,
get/set the applied language via AppCompatDelegate, and render each language's
autonym. The locales_config resource id is now a parameter, so the module is
app-agnostic. appcompat only, no Compose.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 14:12:16 +02:00
367a8ff8af core-reminders: shared reminder-lead plumbing (ReminderUnit, override model + codec)
Pure-Kotlin module, drawn from the duplicate reminder logic in Agendula and
Calendula: the lead-time unit model (ReminderUnit / decomposeReminderMinutes),
the per-target override model (ReminderOverride + reminderLeadFor /
applyReminderOverride) and the stored-format codec (ReminderOverrideCodec).

The codec's separators are configurable so each app keeps its on-disk format
(Agendula stores id:minutes, Calendula id=minutes) without a data migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 14:11:55 +02:00
20 changed files with 979 additions and 2 deletions

View File

@@ -35,9 +35,11 @@ See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the full consumption mode
| Module | Type | What it holds |
|--------|------|---------------|
| `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-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()`. Each app supplies its own seed/palette. |
| `components` | Android | Shared Compose vocabulary: `GroupedSurface`/`GroupedRow`, `InlineTextField`, `OptionCard`, `CollapsingScaffold`, `OptionPicker`, `pastelize()`. |
| `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`, `pastelize()`, `DialogControls`, `OnboardingScaffold`, `OptionalFormSection`, `AboutCard`, `LanguagePickerRow`. |
_The principle: the **mechanics** are shared, the **look** stays per-app. See
[`docs/ROADMAP.md`](docs/ROADMAP.md) for what's extracted, deferred, and
@@ -49,6 +51,7 @@ See [`docs/`](docs/) — start with [`docs/README.md`](docs/README.md)
([ARCHITECTURE](docs/ARCHITECTURE.md), [ROADMAP](docs/ROADMAP.md)) and
[`CONTRIBUTING.md`](CONTRIBUTING.md) to add or change a module.
[`CHANGELOG.md`](CHANGELOG.md) tracks what landed.
>>>>>>> origin/main
## Build

View File

@@ -33,6 +33,12 @@ kotlin {
}
dependencies {
// identity supplies the shared motion (expandEnter/collapseExit) that the
// revealable components animate with.
implementation(project(":identity"))
// core-locale backs the language-picker recipe (AppLanguage).
implementation(project(":core-locale"))
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.foundation)

View File

@@ -0,0 +1,95 @@
package de.jeanlucmakiola.floret.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
/**
* Tonal 3-digit number input shared by the custom reminder/recurrence steps and
* the pickers — the family's [InlineTextField] over a tonal surface, so it
* matches the card/grouped-row design language (not Material's outlined field).
* Non-digit input and anything past three digits is ignored.
*/
@Composable
fun DialogAmountField(
value: String,
onValueChange: (String) -> Unit,
placeholder: String,
) {
// surfaceContainerHighest — the picker/dialog sits on surfaceContainerHigh,
// so anything lower vanishes.
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHighest,
shape = RoundedCornerShape(12.dp),
) {
InlineTextField(
value = value,
onValueChange = { text ->
if (text.length <= 3 && text.all(Char::isDigit)) onValueChange(text)
},
placeholder = placeholder,
textStyle = MaterialTheme.typography.titleMedium,
keyboardType = KeyboardType.Number,
modifier = Modifier
.width(72.dp)
.padding(horizontal = 14.dp, vertical = 12.dp),
)
}
}
/** Tonal dropdown trigger + menu shared by the custom reminder/recurrence steps and pickers. */
@Composable
fun DialogUnitDropdown(
label: String,
entries: List<String>,
onPick: (Int) -> Unit,
) {
var open by remember { mutableStateOf(false) }
Box {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHighest,
shape = RoundedCornerShape(12.dp),
onClick = { open = true },
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 14.dp, end = 8.dp, top = 12.dp, bottom = 12.dp),
) {
Text(text = label, style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.width(4.dp))
Icon(imageVector = Icons.Default.ArrowDropDown, contentDescription = null)
}
}
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
entries.forEachIndexed { index, entry ->
DropdownMenuItem(
text = { Text(entry) },
onClick = {
onPick(index)
open = false
},
)
}
}
}
}

View File

@@ -0,0 +1,26 @@
package de.jeanlucmakiola.floret.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import de.jeanlucmakiola.floret.identity.collapseExit
import de.jeanlucmakiola.floret.identity.expandEnter
/**
* A form section that reveals/hides with the family's expand+fade motion — the
* shared wrapper for the optional fields behind a "More fields" toggle in the
* edit forms. [content] is laid out in a full-width [Column].
*/
@Composable
fun OptionalFormSection(visible: Boolean, content: @Composable ColumnScope.() -> Unit) {
AnimatedVisibility(
visible = visible,
enter = expandEnter(),
exit = collapseExit(),
) {
Column(modifier = Modifier.fillMaxWidth(), content = content)
}
}

View File

@@ -0,0 +1,117 @@
package de.jeanlucmakiola.floret.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
/** MD3 8dp spacing scale shared by the onboarding screens. */
object OnboardingSpace {
val xs = 8.dp
val sm = 16.dp
val md = 24.dp
val lg = 32.dp
val xl = 48.dp
}
/**
* Shared onboarding shell: a scrollable, centred [hero] + [body] with the
* call(s) to action ([actions]) pinned to the bottom (clear of the navigation
* bar). The content slot is centred horizontally; benefit rows fill the width so
* their own content left-aligns. Each app supplies its own hero so the apps read
* as one family while keeping their own mark.
*/
@Composable
fun OnboardingScaffold(
hero: @Composable () -> Unit,
actions: @Composable ColumnScope.() -> Unit,
modifier: Modifier = Modifier,
body: @Composable ColumnScope.() -> Unit,
) {
Scaffold(
modifier = modifier,
containerColor = MaterialTheme.colorScheme.surface,
bottomBar = {
Column(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
.padding(horizontal = OnboardingSpace.md, vertical = OnboardingSpace.sm),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
content = actions,
)
},
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.verticalScroll(rememberScrollState())
.padding(horizontal = OnboardingSpace.md),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(Modifier.height(OnboardingSpace.xl))
hero()
Spacer(Modifier.height(OnboardingSpace.lg))
body()
Spacer(Modifier.height(OnboardingSpace.md))
}
}
}
/** One trust point: a tonal icon chip on the left, title + supporting text right. */
@Composable
fun BenefitRow(icon: ImageVector, title: String, body: String) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.secondaryContainer),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.size(22.dp),
)
}
Spacer(Modifier.width(OnboardingSpace.sm))
Column(modifier = Modifier.weight(1f)) {
Text(text = title, style = MaterialTheme.typography.titleMedium)
Text(
text = body,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}

View File

@@ -0,0 +1,147 @@
package de.jeanlucmakiola.floret.components
import android.content.Intent
import android.net.Uri
import androidx.annotation.XmlRes
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.floret.locale.AppLanguage
/** A labelled, icon-led external link rendered as a button in [AboutCard]. */
data class AboutLink(val icon: ImageVector, val label: String, val url: String)
/**
* The "about this app" card shared across the family: the app's [logo] beside
* its [appName] and [author], then [primaryLinks] as a row of equal-width
* outlined buttons (e.g. source, licence) and an optional full-width
* [highlightLink] tonal button (e.g. donate). Each link opens its URL in the
* browser. The app supplies its own logo, strings and links.
*/
@Composable
fun AboutCard(
logo: @Composable () -> Unit,
appName: String,
author: String,
primaryLinks: List<AboutLink>,
highlightLink: AboutLink? = null,
) {
val context = LocalContext.current
val open = { url: String ->
runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) }
Unit
}
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = RoundedCornerShape(24.dp),
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.fillMaxWidth().padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
logo()
Spacer(Modifier.width(16.dp))
Column(Modifier.weight(1f)) {
Text(appName, style = MaterialTheme.typography.titleLarge)
Text(
text = author,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (primaryLinks.isNotEmpty()) {
Spacer(Modifier.height(12.dp))
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
primaryLinks.forEach { link ->
OutlinedButton(
onClick = { open(link.url) },
contentPadding = PaddingValues(horizontal = 12.dp),
modifier = Modifier.weight(1f),
) {
Icon(link.icon, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text(link.label)
}
}
}
}
if (highlightLink != null) {
Spacer(Modifier.height(8.dp))
FilledTonalButton(onClick = { open(highlightLink.url) }, modifier = Modifier.fillMaxWidth()) {
Icon(highlightLink.icon, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text(highlightLink.label)
}
}
}
}
}
/**
* A settings row that opens a full-screen language picker, backed by
* [AppLanguage]. The choice is mirrored locally so the row updates instantly
* before the activity recreation lands. [autoLabel] names the follow-the-system
* option (the `null` tag); [localesConfig] is the app's `res/xml/locales_config`;
* [leading] is the row's icon.
*/
@Composable
fun LanguagePickerRow(
position: Position,
title: String,
autoLabel: String,
@XmlRes localesConfig: Int,
leading: @Composable () -> Unit,
) {
val context = LocalContext.current
var current by remember { mutableStateOf(AppLanguage.currentTag()) }
var showDialog by remember { mutableStateOf(false) }
// null = follow the system; the rest are BCP-47 tags from locales_config.xml.
val options = remember { listOf<String?>(null) + AppLanguage.supportedTags(context, localesConfig) }
val label: (String?) -> String = { tag -> tag?.let { AppLanguage.displayName(it) } ?: autoLabel }
GroupedRow(
title = title,
summary = label(current),
position = position,
leading = leading,
onClick = { showDialog = true },
)
if (showDialog) {
OptionPicker(
title = title,
options = options,
selected = current,
label = { label(it) },
onSelect = {
current = it
AppLanguage.apply(it)
},
onDismiss = { showDialog = false },
)
}
}

View File

@@ -0,0 +1,46 @@
// 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.
plugins {
alias(libs.plugins.android.library)
}
android {
namespace = "de.jeanlucmakiola.floret.locale"
compileSdk = 37
defaultConfig {
minSdk = 29
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
testOptions {
unitTests {
all { it.useJUnitPlatform() }
isReturnDefaultValues = true
}
}
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
dependencies {
// appcompat brings AppCompatDelegate plus its core (LocaleListCompat) and
// annotation (XmlRes) transitives.
implementation(libs.androidx.appcompat)
testImplementation(libs.junit.jupiter.api)
testRuntimeOnly(libs.junit.jupiter.engine)
testRuntimeOnly(libs.junit.platform.launcher)
testImplementation(libs.truth)
}

View File

@@ -0,0 +1,80 @@
package de.jeanlucmakiola.floret.locale
import android.content.Context
import androidx.annotation.XmlRes
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
import org.xmlpull.v1.XmlPullParser
import java.util.Locale
private const val ANDROID_NS = "http://schemas.android.com/apk/res/android"
/**
* Per-app language via AppCompatDelegate, driven by an app's
* res/xml/locales_config.xml.
*
* That file is the single source of truth for which languages an app ships:
* dropping in a values-<tag> translation and adding a matching `<locale>` entry
* makes the language show up here and in the system per-app-language settings,
* with no other code change. The system-default choice is represented as `null`.
*
* On API 33+ this delegates to the platform per-app-languages API; below that
* the appcompat backport persists the choice itself (manifest `autoStoreLocales`
* service), so we don't mirror it in DataStore. Setting a locale recreates the
* activity, which re-reads the current value for the picker.
*/
object AppLanguage {
/**
* The BCP-47 tags the app ships translations for, in declaration order, as
* listed in [localesConfig] (the app's `res/xml/locales_config.xml`). Returns
* whatever could be parsed; a missing or malformed config yields an empty
* list (the picker then offers only the system-default entry rather than
* crashing).
*/
fun supportedTags(context: Context, @XmlRes localesConfig: Int): List<String> {
val tags = mutableListOf<String>()
val parser = context.resources.getXml(localesConfig)
try {
var event = parser.eventType
while (event != XmlPullParser.END_DOCUMENT) {
if (event == XmlPullParser.START_TAG && parser.name == "locale") {
parser.getAttributeValue(ANDROID_NS, "name")?.let(tags::add)
}
event = parser.next()
}
} catch (_: Exception) {
// Fall back to whatever was parsed before the failure.
} finally {
parser.close()
}
return tags
}
/** The applied app language as a BCP-47 tag, or `null` when following the system. */
fun currentTag(): String? {
val locales = AppCompatDelegate.getApplicationLocales()
return if (locales.isEmpty) null else locales[0]?.toLanguageTag()
}
/** Apply a BCP-47 tag, or `null` to follow the system languages. */
fun apply(tag: String?) {
val locales = if (tag == null) {
LocaleListCompat.getEmptyLocaleList()
} else {
LocaleListCompat.forLanguageTags(tag)
}
AppCompatDelegate.setApplicationLocales(locales)
}
/**
* The autonym for a tag — the language's own name in its own script, e.g.
* "Deutsch", "English", "Français" — so users find their language regardless
* of the current UI language. Capitalised per the language's own rules.
*/
fun displayName(tag: String): String {
val locale = Locale.forLanguageTag(tag)
return locale.getDisplayName(locale)
.replaceFirstChar { if (it.isLowerCase()) it.titlecase(locale) else it.toString() }
}
}

View File

@@ -0,0 +1,20 @@
package de.jeanlucmakiola.floret.locale
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class AppLanguageTest {
@Test
fun `autonym is the language's own name`() {
assertThat(AppLanguage.displayName("en")).isEqualTo("English")
assertThat(AppLanguage.displayName("de")).isEqualTo("Deutsch")
}
@Test
fun `autonym is capitalised per the language's own rules`() {
// French autonyms are lower-case in CLDR ("français"); the picker shows
// them title-cased so each language reads as a proper name.
assertThat(AppLanguage.displayName("fr")).isEqualTo("Français")
}
}

View File

@@ -0,0 +1,32 @@
// core-reminders — pure-Kotlin reminder-lead plumbing shared across the family:
// the lead-time unit model (ReminderUnit / decomposeReminderMinutes), the
// per-target override model (ReminderOverride + resolution helpers) and the
// stored-format codec (ReminderOverrideCodec, separators configurable so each
// app keeps its on-disk format). No Android, no third-party deps — a plain JVM
// library, like core-time; each app layers its own DataStore + string labels on
// top.
plugins {
alias(libs.plugins.kotlin.jvm)
}
java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
dependencies {
testImplementation(libs.junit.jupiter.api)
testRuntimeOnly(libs.junit.jupiter.engine)
testRuntimeOnly(libs.junit.platform.launcher)
testImplementation(libs.truth)
}
tasks.test {
useJUnitPlatform()
}

View File

@@ -0,0 +1,44 @@
package de.jeanlucmakiola.floret.reminders
/**
* A target's override of the default reminder lead time — a list (Agendula) or a
* calendar (Calendula). Targets are keyed by id in the override map; this is the
* choice a picker returns.
*/
sealed interface ReminderOverride {
/** No override — the target uses the global default. */
data object Inherit : ReminderOverride
/** Explicit "no reminder" for this target, regardless of the global default. */
data object None : ReminderOverride
/** A specific lead time in minutes before due. */
data class Minutes(val minutes: Int) : ReminderOverride
}
/**
* The lead time for [id]: its override if one is set (a null map value means an
* explicit "no reminder"), otherwise the global [default]. A null result means
* no reminder. Mirrors the absent/null/value semantics of the override map.
*/
fun Map<Long, Int?>.reminderLeadFor(id: Long, default: Int): Int? =
if (containsKey(id)) this[id] else default
/**
* Read [id]'s entry as a [ReminderOverride]: absent → [Inherit], null → [None],
* a value → [Minutes].
*/
fun Map<Long, Int?>.reminderOverrideFor(id: Long): ReminderOverride = when {
!containsKey(id) -> ReminderOverride.Inherit
this[id] == null -> ReminderOverride.None
else -> ReminderOverride.Minutes(getValue(id)!!)
}
/** Apply [override] for [id] to this override map ([Inherit] removes the key). */
fun MutableMap<Long, Int?>.applyReminderOverride(id: Long, override: ReminderOverride) {
when (override) {
ReminderOverride.Inherit -> remove(id)
ReminderOverride.None -> put(id, null)
is ReminderOverride.Minutes -> put(id, override.minutes)
}
}

View File

@@ -0,0 +1,38 @@
package de.jeanlucmakiola.floret.reminders
/**
* Encodes a per-target reminder-override map (`id → minutes`, where a null value
* is an explicit "no reminder") to and from a single stored string, e.g.
* `"12:30;7:none"`.
*
* The separators are configurable because each app already has bytes on disk in
* its own dialect — Agendula stores `id:minutes`, Calendula `id=minutes` — and
* the stored format must stay stable. Use [DEFAULT] (Agendula's `:` / `;`) or
* build a codec with the app's own separators; never change an app's separators
* after release without a migration.
*/
class ReminderOverrideCodec(
private val entrySep: String = ";",
private val keyValueSep: String = ":",
private val noneToken: String = "none",
) {
/** Decode the stored string into a map (a null value = explicit no-reminder). */
fun parse(stored: String?): Map<Long, Int?> {
if (stored.isNullOrBlank()) return emptyMap()
return stored.split(entrySep).mapNotNull { entry ->
val parts = entry.split(keyValueSep).takeIf { it.size == 2 } ?: return@mapNotNull null
val id = parts[0].toLongOrNull() ?: return@mapNotNull null
val value = if (parts[1] == noneToken) null else parts[1].toIntOrNull() ?: return@mapNotNull null
id to value
}.toMap()
}
/** Encode the map back to its stored string form. */
fun serialize(map: Map<Long, Int?>): String =
map.entries.joinToString(entrySep) { (id, minutes) -> "$id$keyValueSep${minutes ?: noneToken}" }
companion object {
/** Agendula's dialect: `id:minutes` entries joined by `;`. */
val DEFAULT = ReminderOverrideCodec()
}
}

View File

@@ -0,0 +1,31 @@
package de.jeanlucmakiola.floret.reminders
/** The unit of a custom reminder lead time; [minutesFactor] converts to minutes. */
enum class ReminderUnit(val minutesFactor: Int) {
Minutes(1),
Hours(60),
Days(1_440),
Weeks(10_080),
}
/**
* A reminder lead time broken into a whole [amount] of a [unit] — the seed for a
* custom-amount editor. [amount] is null when there is no lead time to show.
*/
data class ReminderAmount(val amount: Int?, val unit: ReminderUnit)
/**
* Decompose a reminder lead time (minutes before due) into the largest unit that
* divides it evenly: 120 → 2 hours, 1 440 → 1 day, 10 080 → 1 week, 90 → 90
* minutes. null or a non-positive lead time → no amount, defaulting to minutes.
*/
fun decomposeReminderMinutes(minutes: Int?): ReminderAmount = when {
minutes == null || minutes <= 0 -> ReminderAmount(null, ReminderUnit.Minutes)
minutes % ReminderUnit.Weeks.minutesFactor == 0 ->
ReminderAmount(minutes / ReminderUnit.Weeks.minutesFactor, ReminderUnit.Weeks)
minutes % ReminderUnit.Days.minutesFactor == 0 ->
ReminderAmount(minutes / ReminderUnit.Days.minutesFactor, ReminderUnit.Days)
minutes % ReminderUnit.Hours.minutesFactor == 0 ->
ReminderAmount(minutes / ReminderUnit.Hours.minutesFactor, ReminderUnit.Hours)
else -> ReminderAmount(minutes, ReminderUnit.Minutes)
}

View File

@@ -0,0 +1,52 @@
package de.jeanlucmakiola.floret.reminders
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class ReminderModelTest {
@Test
fun `decompose picks the largest whole unit`() {
assertThat(decomposeReminderMinutes(120)).isEqualTo(ReminderAmount(2, ReminderUnit.Hours))
assertThat(decomposeReminderMinutes(1_440)).isEqualTo(ReminderAmount(1, ReminderUnit.Days))
assertThat(decomposeReminderMinutes(10_080)).isEqualTo(ReminderAmount(1, ReminderUnit.Weeks))
assertThat(decomposeReminderMinutes(90)).isEqualTo(ReminderAmount(90, ReminderUnit.Minutes))
}
@Test
fun `decompose of null or non-positive has no amount`() {
assertThat(decomposeReminderMinutes(null)).isEqualTo(ReminderAmount(null, ReminderUnit.Minutes))
assertThat(decomposeReminderMinutes(0)).isEqualTo(ReminderAmount(null, ReminderUnit.Minutes))
assertThat(decomposeReminderMinutes(-5)).isEqualTo(ReminderAmount(null, ReminderUnit.Minutes))
}
@Test
fun `reminderLeadFor distinguishes absent, explicit-none and a value`() {
val map = mapOf(1L to 30, 2L to null)
assertThat(map.reminderLeadFor(1L, default = 10)).isEqualTo(30)
assertThat(map.reminderLeadFor(2L, default = 10)).isNull() // explicit no-reminder
assertThat(map.reminderLeadFor(3L, default = 10)).isEqualTo(10) // inherits default
}
@Test
fun `reminderOverrideFor maps absent, null and value to the choice types`() {
val map = mapOf(1L to 30, 2L to null)
assertThat(map.reminderOverrideFor(1L)).isEqualTo(ReminderOverride.Minutes(30))
assertThat(map.reminderOverrideFor(2L)).isEqualTo(ReminderOverride.None)
assertThat(map.reminderOverrideFor(3L)).isEqualTo(ReminderOverride.Inherit)
}
@Test
fun `applyReminderOverride writes, clears and removes entries`() {
val map = mutableMapOf<Long, Int?>()
map.applyReminderOverride(1L, ReminderOverride.Minutes(45))
assertThat(map).containsExactly(1L, 45)
map.applyReminderOverride(1L, ReminderOverride.None)
assertThat(map[1L]).isNull()
assertThat(map).containsKey(1L)
map.applyReminderOverride(1L, ReminderOverride.Inherit)
assertThat(map).doesNotContainKey(1L)
}
}

View File

@@ -0,0 +1,52 @@
package de.jeanlucmakiola.floret.reminders
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class ReminderOverrideCodecTest {
private val codec = ReminderOverrideCodec.DEFAULT
@Test
fun `round-trips ids, minutes and explicit none`() {
val map = mapOf(12L to 30, 7L to null, 3L to 0)
assertThat(codec.parse(codec.serialize(map))).isEqualTo(map)
}
@Test
fun `null or blank parses to empty`() {
assertThat(codec.parse(null)).isEmpty()
assertThat(codec.parse("")).isEmpty()
assertThat(codec.parse(" ")).isEmpty()
}
@Test
fun `empty map serializes to empty string`() {
assertThat(codec.serialize(emptyMap())).isEmpty()
}
@Test
fun `none token decodes to a null value`() {
assertThat(codec.parse("7:none")).isEqualTo(mapOf(7L to null))
}
@Test
fun `garbage entries are dropped, valid ones kept`() {
assertThat(codec.parse("12:30;garbage;x:5;9:notnum;4:10"))
.isEqualTo(mapOf(12L to 30, 4L to 10))
}
@Test
fun `a custom dialect keeps a separate on-disk format`() {
// Calendula's dialect: id=minutes entries.
val calendula = ReminderOverrideCodec(entrySep = ";", keyValueSep = "=", noneToken = "none")
val map = mapOf(12L to 30, 7L to null)
val stored = calendula.serialize(map)
assertThat(stored).contains("12=30")
assertThat(stored).contains("7=none")
assertThat(calendula.parse(stored)).isEqualTo(map)
// The Agendula dialect cannot read Calendula's bytes — the formats are distinct.
assertThat(codec.parse(stored)).isEmpty()
}
}

View File

@@ -4,6 +4,8 @@
agp = "9.2.1"
kotlin = "2.3.21"
coreKtx = "1.19.0"
appcompat = "1.7.1"
activityCompose = "1.13.0"
composeBom = "2026.05.01"
# Material 3 Expressive APIs live in the 1.5 alpha line; pinned to override the BOM.
material3 = "1.5.0-alpha21"
@@ -13,10 +15,13 @@ truth = "1.4.5"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-foundation = { group = "androidx.compose.foundation", name = "foundation" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" }
androidx-animation = { group = "androidx.compose.animation", name = "animation" }
androidx-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" }
junit-jupiter-api = { group = "org.junit.jupiter", name = "junit-jupiter-api", version.ref = "junit" }

View File

@@ -36,4 +36,7 @@ dependencies {
implementation(libs.androidx.ui)
implementation(libs.androidx.foundation)
implementation(libs.androidx.material3)
implementation(libs.androidx.animation)
// activity-compose for PredictiveBackHandler + BackEventCompat (the back-gesture peek).
implementation(libs.androidx.activity.compose)
}

View File

@@ -0,0 +1,87 @@
package de.jeanlucmakiola.floret.identity
import android.provider.Settings
import androidx.activity.BackEventCompat
import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import kotlin.coroutines.cancellation.CancellationException
/**
* Whether the user has asked the system to remove animations (Settings →
* Accessibility → "Remove animations", which sets the global animator duration
* scale to 0). Compose animations do *not* honour this platform flag on their
* own, so the shared motion helpers check it and fall back to no spatial
* movement — respecting the vestibular intent of the setting while keeping state
* changes legible.
*
* Read once at composition; the scale changes rarely and only takes full effect
* after a process restart anyway.
*/
@Composable
fun rememberReduceMotion(): Boolean {
val resolver = LocalContext.current.contentResolver
return remember(resolver) {
Settings.Global.getFloat(resolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f
}
}
/**
* The standard Android predictive-back transform for a full-screen surface: as
* the back gesture is dragged, the surface scales toward ~90%, shifts toward the
* swiped edge and rounds its corners, previewing what's behind. Completing the
* gesture invokes [onBack]; cancelling springs it back.
*
* A drop-in replacement for a screen's own `BackHandler(onBack)` — register it
* once and apply the returned [Modifier] to that screen's root so the preview
* respects the same back semantics. Under reduced motion the visual preview is
* skipped (the back still works); on API < 34 the system delivers no progress,
* so it degrades to a plain back. Shared so the family's apps preview the same
* way.
*
* @param enabled gate the handler — e.g. set false while a screen's own inner
* back (a sub-section) should take the gesture instead.
*/
@Composable
fun Modifier.predictiveBack(
onBack: () -> Unit,
enabled: Boolean = true,
reduceMotion: Boolean = rememberReduceMotion(),
): Modifier {
val progress = remember { Animatable(0f) }
var fromLeftEdge by remember { mutableStateOf(true) }
PredictiveBackHandler(enabled = enabled) { events ->
try {
events.collect { event ->
fromLeftEdge = event.swipeEdge == BackEventCompat.EDGE_LEFT
progress.snapTo(FastOutSlowInEasing.transform(event.progress))
}
onBack()
progress.snapTo(0f)
} catch (_: CancellationException) {
progress.animateTo(0f)
}
}
if (reduceMotion) return this
return this.graphicsLayer {
val p = progress.value
val scale = 1f - 0.1f * p
scaleX = scale
scaleY = scale
translationX = (if (fromLeftEdge) 1f else -1f) * 24.dp.toPx() * p
shape = RoundedCornerShape(32.dp.toPx() * p)
clip = p > 0f
}
}

View File

@@ -0,0 +1,91 @@
package de.jeanlucmakiola.floret.identity
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
/**
* The family's content transitions, driven by the active M3 Expressive motion
* scheme so every app reveals, collapses and re-lays-out the same way. Each
* honours [rememberReduceMotion]: when the user has asked the system to remove
* animations, the spatial movement is dropped and only a quick fade remains.
*
* Drawn from Calendula's motion helpers; pair [expandEnter] with [collapseExit]
* on an `AnimatedVisibility`, use [itemEnter] for content revealed once it
* resolves, [animateItemMotion] for `LazyColumn`/`LazyRow` row relayout, and
* [fadeThrough] to swap whole blocks with no spatial direction.
*/
/**
* Enter for a vertically-revealed section (expandable rows, inline fields):
* height grows from the top while fading in; a quick fade only under reduced
* motion. Pair with [collapseExit].
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun expandEnter(reduceMotion: Boolean = rememberReduceMotion()): EnterTransition {
val fade = fadeIn(MaterialTheme.motionScheme.fastEffectsSpec())
return if (reduceMotion) fade else expandVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + fade
}
/** Exit counterpart to [expandEnter]: shrink + fade, or fade only under reduced motion. */
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun collapseExit(reduceMotion: Boolean = rememberReduceMotion()): ExitTransition {
val fade = fadeOut(MaterialTheme.motionScheme.fastEffectsSpec())
return if (reduceMotion) fade else shrinkVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + fade
}
/**
* Enter for content revealed by an `AnimatedContent`/`AnimatedVisibility` (e.g.
* results once a query resolves): a gentle rise + fade; fade only under reduced
* motion.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun itemEnter(reduceMotion: Boolean = rememberReduceMotion()): EnterTransition {
val fade = fadeIn(MaterialTheme.motionScheme.fastEffectsSpec())
return if (reduceMotion) fade else fade + slideInVertically(MaterialTheme.motionScheme.fastSpatialSpec()) { h -> h / 6 }
}
/**
* `LazyItemScope.animateItem` wired to the app's motion scheme instead of
* Compose's default spring, so list rows fade/relocate consistently. Returns a
* bare [Modifier] under reduced motion so rows snap into place. Requires stable
* item keys.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun LazyItemScope.animateItemMotion(reduceMotion: Boolean = rememberReduceMotion()): Modifier =
if (reduceMotion) {
Modifier
} else {
Modifier.animateItem(
fadeInSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
placementSpec = MaterialTheme.motionScheme.fastSpatialSpec(),
fadeOutSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
)
}
/**
* Cross-fade [ContentTransform] for swapping whole screens or blocks with no
* meaningful spatial direction (e.g. onboarding gates). Pure opacity, so it
* doubles as its own reduced-motion form.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun fadeThrough(): ContentTransform {
val fade = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
return fadeIn(fade).togetherWith(fadeOut(fade))
}

View File

@@ -32,6 +32,8 @@ dependencyResolutionManagement {
rootProject.name = "floret-kit"
include(":core-time")
include(":core-reminders")
include(":core-locale")
include(":core-crash")
include(":identity")
include(":components")