Compare commits
9 Commits
25ec248e73
...
86f44805d1
| Author | SHA1 | Date | |
|---|---|---|---|
| 86f44805d1 | |||
| 09d3f9b934 | |||
| 24ada183e2 | |||
| 1e36c229ca | |||
| ea96e970c7 | |||
| 2085b82a5e | |||
| 10e0ca0b06 | |||
| 4eb9809993 | |||
| 367a8ff8af |
@@ -35,9 +35,11 @@ See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the full consumption mode
|
|||||||
| Module | Type | What it holds |
|
| 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-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`. |
|
| `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. |
|
| `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: `GroupedSurface`/`GroupedRow`, `InlineTextField`, `OptionCard`, `CollapsingScaffold`, `OptionPicker`, `pastelize()`. |
|
| `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
|
_The principle: the **mechanics** are shared, the **look** stays per-app. See
|
||||||
[`docs/ROADMAP.md`](docs/ROADMAP.md) for what's extracted, deferred, and
|
[`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
|
([ARCHITECTURE](docs/ARCHITECTURE.md), [ROADMAP](docs/ROADMAP.md)) and
|
||||||
[`CONTRIBUTING.md`](CONTRIBUTING.md) to add or change a module.
|
[`CONTRIBUTING.md`](CONTRIBUTING.md) to add or change a module.
|
||||||
[`CHANGELOG.md`](CHANGELOG.md) tracks what landed.
|
[`CHANGELOG.md`](CHANGELOG.md) tracks what landed.
|
||||||
|
>>>>>>> origin/main
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ kotlin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
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(platform(libs.androidx.compose.bom))
|
||||||
implementation(libs.androidx.ui)
|
implementation(libs.androidx.ui)
|
||||||
implementation(libs.androidx.foundation)
|
implementation(libs.androidx.foundation)
|
||||||
|
|||||||
@@ -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
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
46
core-locale/build.gradle.kts
Normal file
46
core-locale/build.gradle.kts
Normal 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)
|
||||||
|
}
|
||||||
@@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
32
core-reminders/build.gradle.kts
Normal file
32
core-reminders/build.gradle.kts
Normal 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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@
|
|||||||
agp = "9.2.1"
|
agp = "9.2.1"
|
||||||
kotlin = "2.3.21"
|
kotlin = "2.3.21"
|
||||||
coreKtx = "1.19.0"
|
coreKtx = "1.19.0"
|
||||||
|
appcompat = "1.7.1"
|
||||||
|
activityCompose = "1.13.0"
|
||||||
composeBom = "2026.05.01"
|
composeBom = "2026.05.01"
|
||||||
# Material 3 Expressive APIs live in the 1.5 alpha line; pinned to override the BOM.
|
# Material 3 Expressive APIs live in the 1.5 alpha line; pinned to override the BOM.
|
||||||
material3 = "1.5.0-alpha21"
|
material3 = "1.5.0-alpha21"
|
||||||
@@ -13,10 +15,13 @@ truth = "1.4.5"
|
|||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
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-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||||
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
|
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||||
androidx-foundation = { group = "androidx.compose.foundation", name = "foundation" }
|
androidx-foundation = { group = "androidx.compose.foundation", name = "foundation" }
|
||||||
androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" }
|
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" }
|
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" }
|
junit-jupiter-api = { group = "org.junit.jupiter", name = "junit-jupiter-api", version.ref = "junit" }
|
||||||
|
|||||||
@@ -36,4 +36,7 @@ dependencies {
|
|||||||
implementation(libs.androidx.ui)
|
implementation(libs.androidx.ui)
|
||||||
implementation(libs.androidx.foundation)
|
implementation(libs.androidx.foundation)
|
||||||
implementation(libs.androidx.material3)
|
implementation(libs.androidx.material3)
|
||||||
|
implementation(libs.androidx.animation)
|
||||||
|
// activity-compose for PredictiveBackHandler + BackEventCompat (the back-gesture peek).
|
||||||
|
implementation(libs.androidx.activity.compose)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -32,6 +32,8 @@ dependencyResolutionManagement {
|
|||||||
rootProject.name = "floret-kit"
|
rootProject.name = "floret-kit"
|
||||||
|
|
||||||
include(":core-time")
|
include(":core-time")
|
||||||
|
include(":core-reminders")
|
||||||
|
include(":core-locale")
|
||||||
include(":core-crash")
|
include(":core-crash")
|
||||||
include(":identity")
|
include(":identity")
|
||||||
include(":components")
|
include(":components")
|
||||||
|
|||||||
Reference in New Issue
Block a user