Compare commits
13 Commits
aee26a28f3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 60dd4eea71 | |||
| 2124227a7f | |||
| 78c5fd1fd4 | |||
| e1919cab83 | |||
| 55ad536513 | |||
| cded44207c | |||
| 5a576c4d28 | |||
| 2e47271904 | |||
| 89e3deb9f6 | |||
| 53217f139e | |||
| bd874c2586 | |||
| 566caf4305 | |||
| d9e4e877cc |
35
CHANGELOG.md
35
CHANGELOG.md
@@ -10,6 +10,8 @@ are no binary releases; "version" tracks the shared `version` in the root build
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.1.0] - 2026-07-07
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **`core-time`** (JVM library) — pure-Kotlin date/time helpers, no Android and
|
- **`core-time`** (JVM library) — pure-Kotlin date/time helpers, no Android and
|
||||||
@@ -18,6 +20,8 @@ are no binary releases; "version" tracks the shared `version` in the root build
|
|||||||
- `Instant.formatDate()` / `formatTime()` / `formatDateTime()` — locale- and
|
- `Instant.formatDate()` / `formatTime()` / `formatDateTime()` — locale- and
|
||||||
zone-aware display formatting over `kotlin.time.Instant`.
|
zone-aware display formatting over `kotlin.time.Instant`.
|
||||||
- `TimeBridge` — `Long` epoch-millis ↔ `Instant`, for content-provider I/O.
|
- `TimeBridge` — `Long` epoch-millis ↔ `Instant`, for content-provider I/O.
|
||||||
|
- `LocalDate.isoWeekNumber()` — ISO-8601 week-of-week-based-year, the
|
||||||
|
calendar-week number for week/month grids (one scheme, shared).
|
||||||
- **`core-crash`** (Android library) — privacy-respecting, on-device crash
|
- **`core-crash`** (Android library) — privacy-respecting, on-device crash
|
||||||
capture and reporting. Writes an allowlist-only report (app/Android/device
|
capture and reporting. Writes an allowlist-only report (app/Android/device
|
||||||
version, locale, time, stack trace — nothing else) to private storage and
|
version, locale, time, stack trace — nothing else) to private storage and
|
||||||
@@ -28,8 +32,11 @@ are no binary releases; "version" tracks the shared `version` in the root build
|
|||||||
own thin themed `CrashReportActivity`.
|
own thin themed `CrashReportActivity`.
|
||||||
- **`identity`** (Android library) — the family's Material 3 Expressive theme
|
- **`identity`** (Android library) — the family's Material 3 Expressive theme
|
||||||
factory `FloretExpressiveTheme(lightScheme, darkScheme, …)` (dynamic colour on
|
factory `FloretExpressiveTheme(lightScheme, darkScheme, …)` (dynamic colour on
|
||||||
API 31+, system light/dark fallback, the standard motion scheme) plus
|
API 31+, system light/dark fallback, the standard motion scheme) plus the
|
||||||
`rememberNavSlideSpec()`. Each app passes its own seed-derived colour schemes
|
shared motion helpers — `rememberNavSlideSpec()`, the content transitions,
|
||||||
|
`Modifier.predictiveBack()` and `Modifier.animateContentSizeMotion()` (eases a
|
||||||
|
block's own height changes, e.g. a text field wrapping to a new line), each
|
||||||
|
reduced-motion-aware. Each app passes its own seed-derived colour schemes
|
||||||
and typography — the mechanics are shared, the look is not.
|
and typography — the mechanics are shared, the look is not.
|
||||||
- **`components`** (Android library) — the shared Compose component vocabulary:
|
- **`components`** (Android library) — the shared Compose component vocabulary:
|
||||||
- `GroupedSurface` / `GroupedRow` + `Position` / `positionOf` — the canonical
|
- `GroupedSurface` / `GroupedRow` + `Position` / `positionOf` — the canonical
|
||||||
@@ -37,13 +44,33 @@ are no binary releases; "version" tracks the shared `version` in the root build
|
|||||||
- `InlineTextField` — the borderless tonal text input (capitalization is a
|
- `InlineTextField` — the borderless tonal text input (capitalization is a
|
||||||
parameter).
|
parameter).
|
||||||
- `OptionCard` — the sanctioned selection-dialog pick.
|
- `OptionCard` — the sanctioned selection-dialog pick.
|
||||||
- `CollapsingScaffold` — the collapsing settings/sub-screen scaffold.
|
- `CollapsingScaffold` — the collapsing settings/sub-screen scaffold; set
|
||||||
- `FullScreenPicker` / `OptionPicker` — the full-screen single-select picker.
|
`largeTopBar = false` for a pinned single-line bar (the picker variant).
|
||||||
|
- `FullScreenPicker` / `OptionPicker` — the full-screen single-select picker,
|
||||||
|
now over a pinned single-line bar (a picker is a short list, so no tall
|
||||||
|
collapsing header to scroll past). `FullScreenPicker` takes an optional
|
||||||
|
`actions` slot (app-bar trailing items) for pickers that carry a commit or
|
||||||
|
extra action — a custom-value **Add**/**OK**, a **Reset**.
|
||||||
|
- `ReorderableColumn` — dependency-free drag-to-reorder for the short, fixed
|
||||||
|
grouped-card Settings lists; pairs with `GroupedRow(gapBelow = false)`, which
|
||||||
|
hands the column uniform control of row spacing.
|
||||||
|
- `DebugRibbon` — a stark, un-themed "DEBUG" corner ribbon; gate on
|
||||||
|
`BuildConfig.DEBUG` at the call site.
|
||||||
|
- `InlineTextField` gained `enabled` (a disabled field dims to a locked value).
|
||||||
- `pastelize()` — softens a raw provider colour to a theme-fitting pastel.
|
- `pastelize()` — softens a raw provider colour to a theme-fitting pastel.
|
||||||
|
|
||||||
App-agnostic by design: apps compose their own screens and keep their own
|
App-agnostic by design: apps compose their own screens and keep their own
|
||||||
identity chips/icons. String resources here (e.g. `back`) are fallbacks an app
|
identity chips/icons. String resources here (e.g. `back`) are fallbacks an app
|
||||||
can override.
|
can override.
|
||||||
|
- **`core-reminders`** (JVM library) — the app-agnostic reminder-override model:
|
||||||
|
`ReminderOverride` (`Inherit` / `None` / `Minutes`), where `Minutes` carries a
|
||||||
|
**list** of lead-times so a target can hold several reminders at once (an app
|
||||||
|
that offers a single reminder just uses a one-element list). Map helpers
|
||||||
|
(`reminderOverrideFor`, `reminderLeadsFor`, `applyReminderOverride`,
|
||||||
|
`reminderOverrideForMinutes`, `normalizeReminders`) plus `ReminderOverrideCodec`
|
||||||
|
(three configurable separators; single legacy values round-trip byte-identically)
|
||||||
|
and the lead-time unit model (`ReminderUnit`, `decomposeReminderMinutes`). The
|
||||||
|
labels/presets and the alarm scheduler stay per-app.
|
||||||
|
|
||||||
### Conventions
|
### Conventions
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the full consumption mode
|
|||||||
| `core-locale` | Android | Per-app language plumbing (`AppLanguage`): read the shipped languages from the app's `res/xml/locales_config.xml`, get/set the applied language via `AppCompatDelegate`, render each language's autonym. The app passes its own `locales_config`; appcompat only, no Compose. |
|
| `core-locale` | Android | 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()`, the content transitions (`expandEnter`/`collapseExit`/`itemEnter`/…) and the `predictiveBack` peek. 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 + recipes: `GroupedSurface`/`GroupedRow`, `InlineTextField`, `OptionCard`, `CollapsingScaffold`, `OptionPicker`, `pastelize()`, `DialogControls`, `OnboardingScaffold`, `OptionalFormSection`, `AboutCard`, `LanguagePickerRow`. |
|
| `components` | Android | Shared Compose vocabulary + recipes: `GroupedSurface`/`GroupedRow`, `InlineTextField`, `OptionCard`, `CollapsingScaffold`, `OptionPicker`, `ReorderableColumn`, `DebugRibbon`, `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
|
||||||
@@ -51,7 +51,6 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -10,5 +10,5 @@ plugins {
|
|||||||
|
|
||||||
subprojects {
|
subprojects {
|
||||||
group = "de.jeanlucmakiola.floret"
|
group = "de.jeanlucmakiola.floret"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.floret.components
|
|||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.imePadding
|
import androidx.compose.foundation.layout.imePadding
|
||||||
@@ -18,6 +19,7 @@ import androidx.compose.material3.LargeTopAppBar
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.material3.rememberTopAppBarState
|
import androidx.compose.material3.rememberTopAppBarState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -25,13 +27,25 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The family's collapsing scaffold for settings hubs, sub-screens and the
|
* The family's collapsing scaffold for settings hubs, sub-screens and the
|
||||||
* full-screen pickers: a [LargeTopAppBar] that collapses on scroll, a back
|
* full-screen pickers. By default a [LargeTopAppBar] whose title shrinks into
|
||||||
* button, and a vertically scrolling content column. [content] lays out the
|
* the bar as the content scrolls — for settings and sub-screens, where the large
|
||||||
* connected grouped rows. The back content description uses the kit's `back`
|
* header sets the page — over a vertically scrolling content column. [content]
|
||||||
* string, which an app can override with its own (localized) `back`.
|
* lays out the connected grouped rows. The back content description uses the
|
||||||
|
* kit's `back` string, which an app can override with its own (localized) `back`.
|
||||||
|
*
|
||||||
|
* Set [largeTopBar] to false for a pinned, single-line [TopAppBar] instead: the
|
||||||
|
* title sits in the bar from the start with no expanded header to scroll past.
|
||||||
|
* Preferred for selection pickers, where the tall header is only empty space
|
||||||
|
* above a short list.
|
||||||
|
*
|
||||||
|
* Optional extras, all defaulting to the bare scaffold so existing callers are
|
||||||
|
* unchanged: [actions] adds trailing app-bar items; [snackbarHost] hosts
|
||||||
|
* transient messages; [predictiveBack] (off by default) wires the gesture
|
||||||
|
* preview to [onBack] for full-screen surfaces that want it.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -39,32 +53,55 @@ fun CollapsingScaffold(
|
|||||||
title: String,
|
title: String,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
largeTopBar: Boolean = true,
|
||||||
|
predictiveBack: Boolean = false,
|
||||||
|
actions: @Composable RowScope.() -> Unit = {},
|
||||||
|
snackbarHost: @Composable () -> Unit = {},
|
||||||
content: @Composable ColumnScope.() -> Unit,
|
content: @Composable ColumnScope.() -> Unit,
|
||||||
) {
|
) {
|
||||||
val scrollBehavior =
|
val scrollBehavior = if (largeTopBar) {
|
||||||
TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
|
TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
|
||||||
|
} else {
|
||||||
|
TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||||
|
}
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
|
.then(if (predictiveBack) Modifier.predictiveBack(onBack = onBack) else Modifier)
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(MaterialTheme.colorScheme.surface)
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
topBar = {
|
topBar = {
|
||||||
LargeTopAppBar(
|
val navigationIcon = @Composable {
|
||||||
title = { Text(title) },
|
IconButton(onClick = onBack) {
|
||||||
navigationIcon = {
|
Icon(
|
||||||
IconButton(onClick = onBack) {
|
Icons.AutoMirrored.Rounded.ArrowBack,
|
||||||
Icon(
|
contentDescription = stringResource(R.string.back),
|
||||||
Icons.AutoMirrored.Rounded.ArrowBack,
|
)
|
||||||
contentDescription = stringResource(R.string.back),
|
}
|
||||||
)
|
}
|
||||||
}
|
if (largeTopBar) {
|
||||||
},
|
LargeTopAppBar(
|
||||||
scrollBehavior = scrollBehavior,
|
title = { Text(title) },
|
||||||
colors = TopAppBarDefaults.largeTopAppBarColors(
|
navigationIcon = navigationIcon,
|
||||||
scrolledContainerColor = MaterialTheme.colorScheme.surface,
|
actions = actions,
|
||||||
),
|
scrollBehavior = scrollBehavior,
|
||||||
)
|
colors = TopAppBarDefaults.largeTopAppBarColors(
|
||||||
|
scrolledContainerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(title) },
|
||||||
|
navigationIcon = navigationIcon,
|
||||||
|
actions = actions,
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
scrolledContainerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
snackbarHost = snackbarHost,
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package de.jeanlucmakiola.floret.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
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.rotate
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Flutter-style "DEBUG" corner ribbon, drawn across the top-right corner of
|
||||||
|
* the app. Deliberately a stark, un-themed marker (not a product component) so a
|
||||||
|
* debug build is unmistakable at a glance — gate it on `BuildConfig.DEBUG` at the
|
||||||
|
* call site so it never reaches a release build. Non-interactive: it's a plain
|
||||||
|
* label with no pointer handler, so taps fall through to whatever is beneath it.
|
||||||
|
*
|
||||||
|
* Drop it in as the last child of a full-screen [androidx.compose.foundation.layout.Box]
|
||||||
|
* so it overlays the UI.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun BoxScope.DebugRibbon() {
|
||||||
|
Text(
|
||||||
|
text = "DEBUG",
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 10.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
letterSpacing = 1.sp,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.zIndex(1f)
|
||||||
|
// Push the band out so its midline crosses the very corner, then
|
||||||
|
// rotate it to the classic 45° ribbon.
|
||||||
|
.offset(x = 36.dp, y = 24.dp)
|
||||||
|
.rotate(45f)
|
||||||
|
.background(Color(0xFFB23B00))
|
||||||
|
.width(140.dp)
|
||||||
|
.padding(vertical = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
package de.jeanlucmakiola.floret.components
|
package de.jeanlucmakiola.floret.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
@@ -10,8 +13,12 @@ import androidx.compose.material.icons.Icons
|
|||||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.SegmentedButton
|
||||||
|
import androidx.compose.material3.SegmentedButtonDefaults
|
||||||
|
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -57,6 +64,83 @@ fun DialogAmountField(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The expanded "Custom" editor card shared by the family's presets-plus-custom
|
||||||
|
* pickers (reminder lead time, agenda range, snooze delay). A tonal surface with
|
||||||
|
* the connected-shape that meets the "Custom" row above it (tight top corners,
|
||||||
|
* full bottom), holding — top to bottom — an optional single-choice unit toggle,
|
||||||
|
* then a row of [DialogAmountField] + a live [preview] of what the amount
|
||||||
|
* resolves to + a tonal confirm.
|
||||||
|
*
|
||||||
|
* Purely presentational: the caller owns the amount/unit state and computes
|
||||||
|
* [preview] and [confirmEnabled] from its own domain, so this component carries
|
||||||
|
* no units, ranges, or strings of its own. Pass [unitLabels] empty for an
|
||||||
|
* amount-only editor (no toggle row). [onConfirm] fires on the confirm button.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CustomAmountEditor(
|
||||||
|
amountText: String,
|
||||||
|
onAmountChange: (String) -> Unit,
|
||||||
|
preview: String,
|
||||||
|
setLabel: String,
|
||||||
|
confirmEnabled: Boolean,
|
||||||
|
onConfirm: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
placeholder: String = "10",
|
||||||
|
unitLabels: List<String> = emptyList(),
|
||||||
|
selectedUnit: Int = 0,
|
||||||
|
onUnitChange: (Int) -> Unit = {},
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
// A Position.Bottom shape: tight top corners meeting the row, full bottom.
|
||||||
|
shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 22.dp, bottomEnd = 22.dp),
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
// Unit toggle first (when present) so it stays visible above the
|
||||||
|
// keyboard once the amount field below it is focused and scrolled in.
|
||||||
|
if (unitLabels.isNotEmpty()) {
|
||||||
|
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
unitLabels.forEachIndexed { index, label ->
|
||||||
|
SegmentedButton(
|
||||||
|
selected = index == selectedUnit,
|
||||||
|
onClick = { onUnitChange(index) },
|
||||||
|
shape = SegmentedButtonDefaults.itemShape(index, unitLabels.size),
|
||||||
|
label = { Text(label) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Amount, a live preview of what it resolves to, and confirm — all on
|
||||||
|
// one row, sitting just above the keyboard.
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
DialogAmountField(
|
||||||
|
value = amountText,
|
||||||
|
onValueChange = onAmountChange,
|
||||||
|
placeholder = placeholder,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
Text(
|
||||||
|
text = preview,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
FilledTonalButton(onClick = onConfirm, enabled = confirmEnabled) {
|
||||||
|
Text(setLabel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Tonal dropdown trigger + menu shared by the custom reminder/recurrence steps and pickers. */
|
/** Tonal dropdown trigger + menu shared by the custom reminder/recurrence steps and pickers. */
|
||||||
@Composable
|
@Composable
|
||||||
fun DialogUnitDropdown(
|
fun DialogUnitDropdown(
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ fun groupedShape(position: Position, full: Dp, small: Dp): Shape = when (positio
|
|||||||
* all but the last. Corners morph rounder on press — the expressive shape play.
|
* all but the last. Corners morph rounder on press — the expressive shape play.
|
||||||
* The caller supplies [content] and any horizontal inset via [modifier], so the
|
* The caller supplies [content] and any horizontal inset via [modifier], so the
|
||||||
* same primitive serves both the 16dp-inset lists and full-width detail cards.
|
* same primitive serves both the 16dp-inset lists and full-width detail cards.
|
||||||
|
*
|
||||||
|
* Set [gapBelow] to false to suppress that 2dp separation — for a caller that
|
||||||
|
* owns uniform spacing itself, e.g. [ReorderableColumn], where every slot must
|
||||||
|
* share the same pitch for the drag maths to line up.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -59,6 +63,7 @@ fun GroupedSurface(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onClick: (() -> Unit)? = null,
|
onClick: (() -> Unit)? = null,
|
||||||
color: Color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
color: Color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
gapBelow: Boolean = true,
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
val interaction = remember { MutableInteractionSource() }
|
val interaction = remember { MutableInteractionSource() }
|
||||||
@@ -66,9 +71,10 @@ fun GroupedSurface(
|
|||||||
val full by animateDpAsState(if (pressed) 36.dp else 22.dp, label = "fullCorner")
|
val full by animateDpAsState(if (pressed) 36.dp else 22.dp, label = "fullCorner")
|
||||||
val small by animateDpAsState(if (pressed) 36.dp else 6.dp, label = "smallCorner")
|
val small by animateDpAsState(if (pressed) 36.dp else 6.dp, label = "smallCorner")
|
||||||
val shape = groupedShape(position, full, small)
|
val shape = groupedShape(position, full, small)
|
||||||
val gap = when (position) {
|
val gap = when {
|
||||||
Position.Top, Position.Middle -> Modifier.padding(bottom = 2.dp)
|
!gapBelow -> Modifier
|
||||||
Position.Bottom, Position.Alone -> Modifier
|
position == Position.Top || position == Position.Middle -> Modifier.padding(bottom = 2.dp)
|
||||||
|
else -> Modifier
|
||||||
}
|
}
|
||||||
val base = modifier.fillMaxWidth().then(gap)
|
val base = modifier.fillMaxWidth().then(gap)
|
||||||
if (onClick != null) {
|
if (onClick != null) {
|
||||||
@@ -82,6 +88,13 @@ fun GroupedSurface(
|
|||||||
* One row in a grouped list: an M3 [ListItem] over a [GroupedSurface] (the
|
* One row in a grouped list: an M3 [ListItem] over a [GroupedSurface] (the
|
||||||
* tonal, position-shaped, press-morphing family container). Insets 16dp like
|
* tonal, position-shaped, press-morphing family container). Insets 16dp like
|
||||||
* the lists overview.
|
* the lists overview.
|
||||||
|
*
|
||||||
|
* [selected] highlights the row in the secondary container. [dimmed] fades the
|
||||||
|
* headline and summary to the M3 disabled emphasis while leaving the [trailing]
|
||||||
|
* control at full opacity — for rows that are present but switched off.
|
||||||
|
* [container] overrides the row's tonal colour (e.g. a category tint); ignored
|
||||||
|
* when [selected]. [gapBelow] (see [GroupedSurface]) suppresses the inter-row
|
||||||
|
* gap for a caller that owns its own uniform spacing, e.g. [ReorderableColumn].
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -91,7 +104,10 @@ fun GroupedRow(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
summary: String? = null,
|
summary: String? = null,
|
||||||
selected: Boolean = false,
|
selected: Boolean = false,
|
||||||
|
dimmed: Boolean = false,
|
||||||
|
container: Color? = null,
|
||||||
minHeight: Dp = 72.dp,
|
minHeight: Dp = 72.dp,
|
||||||
|
gapBelow: Boolean = true,
|
||||||
leading: @Composable (() -> Unit)? = null,
|
leading: @Composable (() -> Unit)? = null,
|
||||||
trailing: @Composable (() -> Unit)? = null,
|
trailing: @Composable (() -> Unit)? = null,
|
||||||
onClick: (() -> Unit)? = null,
|
onClick: (() -> Unit)? = null,
|
||||||
@@ -104,19 +120,30 @@ fun GroupedRow(
|
|||||||
supportingColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
supportingColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
trailingIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
trailingIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
)
|
)
|
||||||
|
} else if (dimmed) {
|
||||||
|
// M3 disabled emphasis (0.38α) on the text/leading; the trailing control
|
||||||
|
// stays full-opacity so a toggle reads as live even on a faded row.
|
||||||
|
val muted = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||||
|
ListItemDefaults.colors(
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
headlineColor = muted,
|
||||||
|
leadingIconColor = muted,
|
||||||
|
supportingColor = muted,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
ListItemDefaults.colors(containerColor = Color.Transparent)
|
ListItemDefaults.colors(containerColor = Color.Transparent)
|
||||||
}
|
}
|
||||||
val containerColor = if (selected) {
|
val containerColor = when {
|
||||||
MaterialTheme.colorScheme.secondaryContainer
|
selected -> MaterialTheme.colorScheme.secondaryContainer
|
||||||
} else {
|
container != null -> container
|
||||||
MaterialTheme.colorScheme.surfaceContainerHigh
|
else -> MaterialTheme.colorScheme.surfaceContainerHigh
|
||||||
}
|
}
|
||||||
GroupedSurface(
|
GroupedSurface(
|
||||||
position = position,
|
position = position,
|
||||||
modifier = modifier.padding(horizontal = 16.dp),
|
modifier = modifier.padding(horizontal = 16.dp),
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
color = containerColor,
|
color = containerColor,
|
||||||
|
gapBelow = gapBelow,
|
||||||
) {
|
) {
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text(title) },
|
headlineContent = { Text(title) },
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ fun InlineTextField(
|
|||||||
textStyle: TextStyle = MaterialTheme.typography.titleMedium,
|
textStyle: TextStyle = MaterialTheme.typography.titleMedium,
|
||||||
singleLine: Boolean = true,
|
singleLine: Boolean = true,
|
||||||
minLines: Int = 1,
|
minLines: Int = 1,
|
||||||
|
enabled: Boolean = true,
|
||||||
keyboardType: KeyboardType = KeyboardType.Text,
|
keyboardType: KeyboardType = KeyboardType.Text,
|
||||||
capitalization: KeyboardCapitalization = KeyboardCapitalization.Sentences,
|
capitalization: KeyboardCapitalization = KeyboardCapitalization.Sentences,
|
||||||
imeAction: ImeAction = ImeAction.Default,
|
imeAction: ImeAction = ImeAction.Default,
|
||||||
@@ -43,15 +44,17 @@ fun InlineTextField(
|
|||||||
onImeAction: (() -> Unit)? = null,
|
onImeAction: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val resolvedStyle = textStyle.copy(
|
val resolvedStyle = textStyle.copy(
|
||||||
color = if (textStyle.color.isSpecified) {
|
color = when {
|
||||||
textStyle.color
|
// A disabled field reads as dimmed, like a locked value.
|
||||||
} else {
|
!enabled -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
MaterialTheme.colorScheme.onSurface
|
textStyle.color.isSpecified -> textStyle.color
|
||||||
|
else -> MaterialTheme.colorScheme.onSurface
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
BasicTextField(
|
BasicTextField(
|
||||||
value = value,
|
value = value,
|
||||||
onValueChange = onValueChange,
|
onValueChange = onValueChange,
|
||||||
|
enabled = enabled,
|
||||||
textStyle = resolvedStyle,
|
textStyle = resolvedStyle,
|
||||||
singleLine = singleLine,
|
singleLine = singleLine,
|
||||||
minLines = minLines,
|
minLines = minLines,
|
||||||
|
|||||||
@@ -1,24 +1,32 @@
|
|||||||
package de.jeanlucmakiola.floret.components
|
package de.jeanlucmakiola.floret.components
|
||||||
|
|
||||||
|
import android.view.WindowManager
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.rounded.Check
|
import androidx.compose.material.icons.rounded.Check
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
|
import androidx.compose.ui.platform.LocalView
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.compose.ui.window.DialogProperties
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import androidx.compose.ui.window.DialogWindowProvider
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full-screen scaffold for selection pickers: a full-bleed [Dialog] that reuses
|
* Full-screen scaffold for selection pickers: a full-bleed [Dialog] that reuses
|
||||||
* [CollapsingScaffold] (collapsing title + back), so a picker is visually
|
* [CollapsingScaffold] with a pinned single-line bar, so a picker uses the full
|
||||||
* identical to a settings sub-page and uses the full width. [content] places the
|
* width without a tall header — it's a short selection list, so the large
|
||||||
* connected grouped rows; selecting one calls [onDismiss].
|
* collapsing header would only be empty space to scroll past. [content] places
|
||||||
|
* the connected grouped rows; selecting one calls [onDismiss].
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun FullScreenPicker(
|
fun FullScreenPicker(
|
||||||
title: String,
|
title: String,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
|
predictiveBack: Boolean = false,
|
||||||
|
actions: @Composable RowScope.() -> Unit = {},
|
||||||
content: @Composable ColumnScope.() -> Unit,
|
content: @Composable ColumnScope.() -> Unit,
|
||||||
) {
|
) {
|
||||||
Dialog(
|
Dialog(
|
||||||
@@ -28,7 +36,23 @@ fun FullScreenPicker(
|
|||||||
decorFitsSystemWindows = false,
|
decorFitsSystemWindows = false,
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
CollapsingScaffold(title = title, onBack = onDismiss, content = content)
|
// The dialog window pans by default when the keyboard opens, which —
|
||||||
|
// combined with the content's own imePadding — leaves a fixed black gap
|
||||||
|
// above the keyboard. Switch it to ADJUST_NOTHING so the window stays
|
||||||
|
// full-screen and imePadding alone lifts the focused field.
|
||||||
|
val view = LocalView.current
|
||||||
|
SideEffect {
|
||||||
|
(view.parent as? DialogWindowProvider)?.window
|
||||||
|
?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
|
||||||
|
}
|
||||||
|
CollapsingScaffold(
|
||||||
|
title = title,
|
||||||
|
onBack = onDismiss,
|
||||||
|
largeTopBar = false,
|
||||||
|
predictiveBack = predictiveBack,
|
||||||
|
actions = actions,
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,8 +70,11 @@ fun <T> OptionPicker(
|
|||||||
onSelect: (T) -> Unit,
|
onSelect: (T) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
leading: (@Composable (T) -> Unit)? = null,
|
leading: (@Composable (T) -> Unit)? = null,
|
||||||
|
header: (@Composable ColumnScope.() -> Unit)? = null,
|
||||||
|
predictiveBack: Boolean = false,
|
||||||
) {
|
) {
|
||||||
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
FullScreenPicker(title = title, onDismiss = onDismiss, predictiveBack = predictiveBack) {
|
||||||
|
header?.invoke(this)
|
||||||
options.forEachIndexed { index, option ->
|
options.forEachIndexed { index, option ->
|
||||||
val isSelected = option == selected
|
val isSelected = option == selected
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
package de.jeanlucmakiola.floret.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.Spring
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.snap
|
||||||
|
import androidx.compose.animation.core.spring
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.gestures.detectDragGestures
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/** Uniform row height for [ReorderableColumn]; a fixed pitch keeps drag maths exact. */
|
||||||
|
val ReorderableRowHeight: Dp = 64.dp
|
||||||
|
private val RowGap: Dp = 2.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A vertical list whose rows can be dragged into a new order by their handle.
|
||||||
|
*
|
||||||
|
* Built for the short, fixed grouped-card lists in Settings — no external
|
||||||
|
* dependency and no [androidx.compose.foundation.lazy.LazyColumn] (the settings
|
||||||
|
* screens are a single [androidx.compose.foundation.verticalScroll] column, which
|
||||||
|
* can't nest a scrolling list). Rows are a fixed [ReorderableRowHeight] with a
|
||||||
|
* uniform gap, so a row's target slot is simply how many whole pitches it has
|
||||||
|
* been dragged. The held row follows the finger while the others slide out of
|
||||||
|
* its way (animated); on release the order is committed immediately with a
|
||||||
|
* single [onReorder] call, then the held row eases into its new slot as a
|
||||||
|
* purely visual settle — safe to interrupt with another drag, since the
|
||||||
|
* commit itself never waits on it.
|
||||||
|
*
|
||||||
|
* [rowContent] receives the [Position] for the row's place in the order (to reuse
|
||||||
|
* [GroupedRow]'s card shaping — pass `gapBelow = false` there, this owns spacing)
|
||||||
|
* and a `dragHandle` [Modifier] to attach to the element that starts a drag.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun <T> ReorderableColumn(
|
||||||
|
items: List<T>,
|
||||||
|
keyOf: (T) -> Any,
|
||||||
|
onReorder: (List<T>) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
rowContent: @Composable (item: T, position: Position, dragHandle: Modifier, isDragging: Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
val pitchPx = with(LocalDensity.current) { (ReorderableRowHeight + RowGap).toPx() }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
// Local working copy; re-seeded when the incoming list changes (including the
|
||||||
|
// echo of our own committed order).
|
||||||
|
var order by remember(items) { mutableStateOf(items) }
|
||||||
|
var draggedKey by remember { mutableStateOf<Any?>(null) }
|
||||||
|
// Live translation of the held row from its slot (px); also drives the
|
||||||
|
// release settle — re-based onto the row's new slot once the order commits
|
||||||
|
// (see onDragEnd), then eased down to zero.
|
||||||
|
var dragOffset by remember { mutableFloatStateOf(0f) }
|
||||||
|
// The running release/cancel settle, cancelled if a new drag pre-empts it.
|
||||||
|
// Purely visual — the order commit (see onDragEnd) never depends on it.
|
||||||
|
var settleJob by remember { mutableStateOf<Job?>(null) }
|
||||||
|
|
||||||
|
val draggedIndex = draggedKey?.let { key -> order.indexOfFirst { keyOf(it) == key }.takeIf { it >= 0 } }
|
||||||
|
// Whole slots dragged → the slot the held row currently hovers over. Derived
|
||||||
|
// so recomposition only fires when the *target slot* actually changes, not on
|
||||||
|
// every dragged pixel: dragOffset moves every frame during a drag, but the
|
||||||
|
// held row's own translation is already applied in the draw phase via
|
||||||
|
// graphicsLayer below, so only the neighbours' shift (which depends on this)
|
||||||
|
// needs to recompose, and only when a slot boundary is actually crossed.
|
||||||
|
// (Read as a plain val, not `by`, below: a delegated property has a custom
|
||||||
|
// getter and Kotlin won't smart-cast it in the `when` over `targetIndex` in
|
||||||
|
// the loop, so the derived value is captured into a real local instead.)
|
||||||
|
val targetIndex = remember(items, pitchPx) {
|
||||||
|
derivedStateOf {
|
||||||
|
val idx = draggedKey?.let { key -> order.indexOfFirst { keyOf(it) == key }.takeIf { it >= 0 } }
|
||||||
|
idx?.let { (it + (dragOffset / pitchPx).roundToInt()).coerceIn(0, order.lastIndex) }
|
||||||
|
}
|
||||||
|
}.value
|
||||||
|
|
||||||
|
Column(modifier, verticalArrangement = Arrangement.spacedBy(RowGap)) {
|
||||||
|
order.forEachIndexed { index, item ->
|
||||||
|
val key = keyOf(item)
|
||||||
|
val isDragged = key == draggedKey
|
||||||
|
|
||||||
|
// Slide neighbours by one pitch to open the gap the held row will drop
|
||||||
|
// into. Snap (not animate) once idle, so committing the new order — which
|
||||||
|
// moves each row's slot — doesn't visibly fight a lingering animation.
|
||||||
|
val shift = when {
|
||||||
|
draggedIndex == null || targetIndex == null || isDragged -> 0f
|
||||||
|
index in (draggedIndex + 1)..targetIndex -> -pitchPx
|
||||||
|
index in targetIndex until draggedIndex -> pitchPx
|
||||||
|
else -> 0f
|
||||||
|
}
|
||||||
|
val animatedShift by animateFloatAsState(
|
||||||
|
targetValue = shift,
|
||||||
|
animationSpec = if (draggedKey != null) spring(stiffness = Spring.StiffnessMediumLow) else snap(),
|
||||||
|
label = "reorderShift",
|
||||||
|
)
|
||||||
|
|
||||||
|
val dragHandle = Modifier.pointerInput(key) {
|
||||||
|
detectDragGestures(
|
||||||
|
onDragStart = {
|
||||||
|
settleJob?.cancel()
|
||||||
|
draggedKey = key
|
||||||
|
dragOffset = 0f
|
||||||
|
},
|
||||||
|
onDrag = { change, amount ->
|
||||||
|
change.consume()
|
||||||
|
dragOffset += amount.y
|
||||||
|
},
|
||||||
|
onDragEnd = {
|
||||||
|
val from = order.indexOfFirst { keyOf(it) == key }
|
||||||
|
if (from < 0) return@detectDragGestures
|
||||||
|
val to = (from + (dragOffset / pitchPx).roundToInt()).coerceIn(0, order.lastIndex)
|
||||||
|
if (to != from) {
|
||||||
|
// Commit synchronously and unconditionally, before the settle
|
||||||
|
// animation below runs — the commit must not depend on that
|
||||||
|
// coroutine reaching its end, or a new drag starting within the
|
||||||
|
// ~160ms settle window would cancel it and silently revert an
|
||||||
|
// already-finished reorder.
|
||||||
|
order = order.toMutableList().apply { add(to, removeAt(from)) }
|
||||||
|
onReorder(order)
|
||||||
|
// The row now lays out at slot `to` instead of `from`; re-base
|
||||||
|
// the live offset onto that new slot (same visual position,
|
||||||
|
// expressed relative to the new one) so the settle below eases
|
||||||
|
// it the rest of the way instead of jumping.
|
||||||
|
dragOffset -= (to - from) * pitchPx
|
||||||
|
}
|
||||||
|
settleJob = scope.launch {
|
||||||
|
// Purely visual from here: ease the held row onto its slot, then
|
||||||
|
// release the drag state. Safe to cancel — the order was already
|
||||||
|
// committed above.
|
||||||
|
Animatable(dragOffset).animateTo(0f, tween(160)) { dragOffset = value }
|
||||||
|
draggedKey = null
|
||||||
|
dragOffset = 0f
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDragCancel = {
|
||||||
|
settleJob = scope.launch {
|
||||||
|
Animatable(dragOffset).animateTo(0f, tween(160)) { dragOffset = value }
|
||||||
|
draggedKey = null
|
||||||
|
dragOffset = 0f
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.height(ReorderableRowHeight)
|
||||||
|
.zIndex(if (isDragged) 1f else 0f)
|
||||||
|
.graphicsLayer {
|
||||||
|
translationY = if (isDragged) dragOffset else animatedShift
|
||||||
|
if (isDragged) {
|
||||||
|
scaleX = 1.02f
|
||||||
|
scaleY = 1.02f
|
||||||
|
shadowElevation = 8.dp.toPx()
|
||||||
|
shape = RoundedCornerShape(20.dp)
|
||||||
|
clip = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
rowContent(item, positionOf(index, order.size), dragHandle, isDragged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
package de.jeanlucmakiola.floret.reminders
|
package de.jeanlucmakiola.floret.reminders
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A target's override of the default reminder lead time — a list (Agendula) or a
|
* A target's override of the default reminder lead times — a list (Agendula) or
|
||||||
* calendar (Calendula). Targets are keyed by id in the override map; this is the
|
* a calendar (Calendula). A target can carry several lead times at once ("1 week
|
||||||
* choice a picker returns.
|
* before" *and* "on the day"), so the value is a set of minutes-before-due;
|
||||||
|
* an app that only offers a single reminder just uses a one-element list.
|
||||||
|
* Targets are keyed by id in the override map; this is the choice a picker
|
||||||
|
* returns.
|
||||||
*/
|
*/
|
||||||
sealed interface ReminderOverride {
|
sealed interface ReminderOverride {
|
||||||
/** No override — the target uses the global default. */
|
/** No override — the target uses the global default. */
|
||||||
@@ -12,33 +15,54 @@ sealed interface ReminderOverride {
|
|||||||
/** Explicit "no reminder" for this target, regardless of the global default. */
|
/** Explicit "no reminder" for this target, regardless of the global default. */
|
||||||
data object None : ReminderOverride
|
data object None : ReminderOverride
|
||||||
|
|
||||||
/** A specific lead time in minutes before due. */
|
/** Specific lead times in minutes before due (non-empty, distinct, ascending). */
|
||||||
data class Minutes(val minutes: Int) : ReminderOverride
|
data class Minutes(val minutes: List<Int>) : ReminderOverride
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Distinct, ascending lead times — the canonical form stored and resolved. */
|
||||||
* The lead time for [id]: its override if one is set (a null map value means an
|
fun List<Int>.normalizeReminders(): List<Int> = distinct().sorted()
|
||||||
* 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],
|
* The lead times for [id]: its override if one is set (an empty-list value means
|
||||||
* a value → [Minutes].
|
* an explicit "no reminder"), otherwise the global [default]. An empty result
|
||||||
|
* means no reminder. Mirrors the absent/empty/value semantics of the override
|
||||||
|
* map.
|
||||||
*/
|
*/
|
||||||
fun Map<Long, Int?>.reminderOverrideFor(id: Long): ReminderOverride = when {
|
fun Map<Long, List<Int>>.reminderLeadsFor(id: Long, default: List<Int>): List<Int> =
|
||||||
|
if (containsKey(id)) getValue(id) else default
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read [id]'s entry as a [ReminderOverride]: absent → [Inherit], empty → [None],
|
||||||
|
* a non-empty value → [Minutes].
|
||||||
|
*/
|
||||||
|
fun Map<Long, List<Int>>.reminderOverrideFor(id: Long): ReminderOverride = when {
|
||||||
!containsKey(id) -> ReminderOverride.Inherit
|
!containsKey(id) -> ReminderOverride.Inherit
|
||||||
this[id] == null -> ReminderOverride.None
|
getValue(id).isEmpty() -> ReminderOverride.None
|
||||||
else -> ReminderOverride.Minutes(getValue(id)!!)
|
else -> ReminderOverride.Minutes(getValue(id))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Apply [override] for [id] to this override map ([Inherit] removes the key). */
|
/** Apply [override] for [id] to this override map ([Inherit] removes the key). */
|
||||||
fun MutableMap<Long, Int?>.applyReminderOverride(id: Long, override: ReminderOverride) {
|
fun MutableMap<Long, List<Int>>.applyReminderOverride(id: Long, override: ReminderOverride) {
|
||||||
when (override) {
|
when (override) {
|
||||||
ReminderOverride.Inherit -> remove(id)
|
ReminderOverride.Inherit -> remove(id)
|
||||||
ReminderOverride.None -> put(id, null)
|
ReminderOverride.None -> put(id, emptyList())
|
||||||
is ReminderOverride.Minutes -> put(id, override.minutes)
|
is ReminderOverride.Minutes -> put(id, override.minutes.normalizeReminders())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The override a chosen lead-time set maps to when emitted from a picker. A
|
||||||
|
* non-empty set is [ReminderOverride.Minutes]; an empty set (the last time was
|
||||||
|
* cleared) reverts to [ReminderOverride.Inherit] on a per-target picker
|
||||||
|
* ([allowInherit]) — so an accidental clear can't silently wipe the target's
|
||||||
|
* default — and to explicit [ReminderOverride.None] otherwise (the global
|
||||||
|
* default, where empty legitimately means no reminder). Pure, for unit tests.
|
||||||
|
*/
|
||||||
|
fun reminderOverrideForMinutes(minutes: List<Int>, allowInherit: Boolean): ReminderOverride {
|
||||||
|
val norm = minutes.normalizeReminders()
|
||||||
|
return when {
|
||||||
|
norm.isNotEmpty() -> ReminderOverride.Minutes(norm)
|
||||||
|
allowInherit -> ReminderOverride.Inherit
|
||||||
|
else -> ReminderOverride.None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,55 @@
|
|||||||
package de.jeanlucmakiola.floret.reminders
|
package de.jeanlucmakiola.floret.reminders
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encodes a per-target reminder-override map (`id → minutes`, where a null value
|
* Encodes a per-target reminder-override map (`id → lead times`, where an empty
|
||||||
* is an explicit "no reminder") to and from a single stored string, e.g.
|
* list is an explicit "no reminder") to and from a single stored string, e.g.
|
||||||
* `"12:30;7:none"`.
|
* `"12:30,60;7:none"`.
|
||||||
*
|
*
|
||||||
* The separators are configurable because each app already has bytes on disk in
|
* Three separators, all configurable because each app already has bytes on disk
|
||||||
* its own dialect — Agendula stores `id:minutes`, Calendula `id=minutes` — and
|
* in its own dialect and the stored format must stay stable:
|
||||||
* the stored format must stay stable. Use [DEFAULT] (Agendula's `:` / `;`) or
|
* - [entrySep] between targets (`;`)
|
||||||
* build a codec with the app's own separators; never change an app's separators
|
* - [keyValueSep] between a target id and its lead times (Agendula `:`,
|
||||||
* after release without a migration.
|
* Calendula `=`)
|
||||||
|
* - [listSep] between lead times within one target (`,`)
|
||||||
|
*
|
||||||
|
* A single legacy value (`id:30`, no [listSep]) still parses to a one-element
|
||||||
|
* list, and a one-element list serialises back with no [listSep] — so an app
|
||||||
|
* that stored a single value per target before multi-reminders round-trips
|
||||||
|
* byte-identically. Only the exact [noneToken] means an explicit no-reminder
|
||||||
|
* override (empty list); a non-sentinel value that parses to no valid minutes is
|
||||||
|
* garbage and drops the entry, so the target inherits the global default rather
|
||||||
|
* than silently reading as "no reminder". Never change an app's separators after
|
||||||
|
* release without a migration.
|
||||||
*/
|
*/
|
||||||
class ReminderOverrideCodec(
|
class ReminderOverrideCodec(
|
||||||
private val entrySep: String = ";",
|
private val entrySep: String = ";",
|
||||||
private val keyValueSep: String = ":",
|
private val keyValueSep: String = ":",
|
||||||
|
private val listSep: String = ",",
|
||||||
private val noneToken: String = "none",
|
private val noneToken: String = "none",
|
||||||
) {
|
) {
|
||||||
/** Decode the stored string into a map (a null value = explicit no-reminder). */
|
/** Decode the stored string into a map (an empty-list value = explicit no-reminder). */
|
||||||
fun parse(stored: String?): Map<Long, Int?> {
|
fun parse(stored: String?): Map<Long, List<Int>> {
|
||||||
if (stored.isNullOrBlank()) return emptyMap()
|
if (stored.isNullOrBlank()) return emptyMap()
|
||||||
return stored.split(entrySep).mapNotNull { entry ->
|
return stored.split(entrySep).mapNotNull { entry ->
|
||||||
val parts = entry.split(keyValueSep).takeIf { it.size == 2 } ?: return@mapNotNull null
|
val parts = entry.split(keyValueSep).takeIf { it.size == 2 } ?: return@mapNotNull null
|
||||||
val id = parts[0].toLongOrNull() ?: 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
|
when (val value = parts[1]) {
|
||||||
id to value
|
noneToken -> id to emptyList()
|
||||||
|
else -> value.split(listSep).mapNotNull { it.trim().toIntOrNull() }
|
||||||
|
.normalizeReminders().takeIf { it.isNotEmpty() }?.let { id to it }
|
||||||
|
?: return@mapNotNull null
|
||||||
|
}
|
||||||
}.toMap()
|
}.toMap()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Encode the map back to its stored string form. */
|
/** Encode the map back to its stored string form. */
|
||||||
fun serialize(map: Map<Long, Int?>): String =
|
fun serialize(map: Map<Long, List<Int>>): String =
|
||||||
map.entries.joinToString(entrySep) { (id, minutes) -> "$id$keyValueSep${minutes ?: noneToken}" }
|
map.entries.joinToString(entrySep) { (id, minutes) ->
|
||||||
|
val value =
|
||||||
|
if (minutes.isEmpty()) noneToken
|
||||||
|
else minutes.normalizeReminders().joinToString(listSep) { it.toString() }
|
||||||
|
"$id$keyValueSep$value"
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** Agendula's dialect: `id:minutes` entries joined by `;`. */
|
/** Agendula's dialect: `id:minutes` entries joined by `;`. */
|
||||||
|
|||||||
@@ -21,32 +21,47 @@ class ReminderModelTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `reminderLeadFor distinguishes absent, explicit-none and a value`() {
|
fun `normalize dedupes and sorts`() {
|
||||||
val map = mapOf(1L to 30, 2L to null)
|
assertThat(listOf(60, 10, 60, 0).normalizeReminders()).containsExactly(0, 10, 60).inOrder()
|
||||||
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
|
@Test
|
||||||
fun `reminderOverrideFor maps absent, null and value to the choice types`() {
|
fun `reminderLeadsFor distinguishes absent, explicit-none and a value`() {
|
||||||
val map = mapOf(1L to 30, 2L to null)
|
val map = mapOf(1L to listOf(30, 60), 2L to emptyList())
|
||||||
assertThat(map.reminderOverrideFor(1L)).isEqualTo(ReminderOverride.Minutes(30))
|
assertThat(map.reminderLeadsFor(1L, default = listOf(10))).containsExactly(30, 60).inOrder()
|
||||||
|
assertThat(map.reminderLeadsFor(2L, default = listOf(10))).isEmpty() // explicit no-reminder
|
||||||
|
assertThat(map.reminderLeadsFor(3L, default = listOf(10))).containsExactly(10) // inherits default
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reminderOverrideFor maps absent, empty and value to the choice types`() {
|
||||||
|
val map = mapOf(1L to listOf(30, 60), 2L to emptyList())
|
||||||
|
assertThat(map.reminderOverrideFor(1L)).isEqualTo(ReminderOverride.Minutes(listOf(30, 60)))
|
||||||
assertThat(map.reminderOverrideFor(2L)).isEqualTo(ReminderOverride.None)
|
assertThat(map.reminderOverrideFor(2L)).isEqualTo(ReminderOverride.None)
|
||||||
assertThat(map.reminderOverrideFor(3L)).isEqualTo(ReminderOverride.Inherit)
|
assertThat(map.reminderOverrideFor(3L)).isEqualTo(ReminderOverride.Inherit)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `applyReminderOverride writes, clears and removes entries`() {
|
fun `applyReminderOverride writes normalized, clears and removes entries`() {
|
||||||
val map = mutableMapOf<Long, Int?>()
|
val map = mutableMapOf<Long, List<Int>>()
|
||||||
map.applyReminderOverride(1L, ReminderOverride.Minutes(45))
|
map.applyReminderOverride(1L, ReminderOverride.Minutes(listOf(60, 45, 45)))
|
||||||
assertThat(map).containsExactly(1L, 45)
|
assertThat(map.getValue(1L)).containsExactly(45, 60).inOrder() // deduped + sorted
|
||||||
|
|
||||||
map.applyReminderOverride(1L, ReminderOverride.None)
|
map.applyReminderOverride(1L, ReminderOverride.None)
|
||||||
assertThat(map[1L]).isNull()
|
assertThat(map.getValue(1L)).isEmpty()
|
||||||
assertThat(map).containsKey(1L)
|
assertThat(map).containsKey(1L)
|
||||||
|
|
||||||
map.applyReminderOverride(1L, ReminderOverride.Inherit)
|
map.applyReminderOverride(1L, ReminderOverride.Inherit)
|
||||||
assertThat(map).doesNotContainKey(1L)
|
assertThat(map).doesNotContainKey(1L)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reminderOverrideForMinutes maps an empty set to inherit or none by allowInherit`() {
|
||||||
|
assertThat(reminderOverrideForMinutes(listOf(60, 10, 10), allowInherit = true))
|
||||||
|
.isEqualTo(ReminderOverride.Minutes(listOf(10, 60)))
|
||||||
|
assertThat(reminderOverrideForMinutes(emptyList(), allowInherit = true))
|
||||||
|
.isEqualTo(ReminderOverride.Inherit)
|
||||||
|
assertThat(reminderOverrideForMinutes(emptyList(), allowInherit = false))
|
||||||
|
.isEqualTo(ReminderOverride.None)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ class ReminderOverrideCodecTest {
|
|||||||
private val codec = ReminderOverrideCodec.DEFAULT
|
private val codec = ReminderOverrideCodec.DEFAULT
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `round-trips ids, minutes and explicit none`() {
|
fun `round-trips ids, multi-value minutes and explicit none`() {
|
||||||
val map = mapOf(12L to 30, 7L to null, 3L to 0)
|
val map = mapOf(12L to listOf(30, 60), 7L to emptyList(), 3L to listOf(0))
|
||||||
assertThat(codec.parse(codec.serialize(map))).isEqualTo(map)
|
assertThat(codec.parse(codec.serialize(map))).isEqualTo(map)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,24 +26,33 @@ class ReminderOverrideCodecTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `none token decodes to a null value`() {
|
fun `none token decodes to an empty-list value`() {
|
||||||
assertThat(codec.parse("7:none")).isEqualTo(mapOf(7L to null))
|
assertThat(codec.parse("7:none")).isEqualTo(mapOf(7L to emptyList<Int>()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `garbage entries are dropped, valid ones kept`() {
|
fun `a legacy single value round-trips byte-identically`() {
|
||||||
assertThat(codec.parse("12:30;garbage;x:5;9:notnum;4:10"))
|
// Pre-multi-reminders each target stored one value ("30"); it must still
|
||||||
.isEqualTo(mapOf(12L to 30, 4L to 10))
|
// parse to a one-element list and serialise back with no list separator.
|
||||||
|
assertThat(codec.parse("12:30")).isEqualTo(mapOf(12L to listOf(30)))
|
||||||
|
assertThat(codec.serialize(mapOf(12L to listOf(30)))).isEqualTo("12:30")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `garbage entries are dropped, none is kept, valid ones kept`() {
|
||||||
|
// 9:notnum parses to no valid minutes → dropped (inherit), not read as none.
|
||||||
|
assertThat(codec.parse("12:30,60;garbage;x:5;9:notnum;7:none;4:10"))
|
||||||
|
.isEqualTo(mapOf(12L to listOf(30, 60), 7L to emptyList(), 4L to listOf(10)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `a custom dialect keeps a separate on-disk format`() {
|
fun `a custom dialect keeps a separate on-disk format`() {
|
||||||
// Calendula's dialect: id=minutes entries.
|
// Calendula's dialect: id=minutes entries.
|
||||||
val calendula = ReminderOverrideCodec(entrySep = ";", keyValueSep = "=", noneToken = "none")
|
val calendula = ReminderOverrideCodec(entrySep = ";", keyValueSep = "=", listSep = ",", noneToken = "none")
|
||||||
val map = mapOf(12L to 30, 7L to null)
|
val map = mapOf(12L to listOf(30, 60), 7L to emptyList())
|
||||||
val stored = calendula.serialize(map)
|
val stored = calendula.serialize(map)
|
||||||
|
|
||||||
assertThat(stored).contains("12=30")
|
assertThat(stored).contains("12=30,60")
|
||||||
assertThat(stored).contains("7=none")
|
assertThat(stored).contains("7=none")
|
||||||
assertThat(calendula.parse(stored)).isEqualTo(map)
|
assertThat(calendula.parse(stored)).isEqualTo(map)
|
||||||
// The Agendula dialect cannot read Calendula's bytes — the formats are distinct.
|
// The Agendula dialect cannot read Calendula's bytes — the formats are distinct.
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package de.jeanlucmakiola.floret.time
|
||||||
|
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.temporal.IsoFields
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ISO-8601 week-of-week-based-year (1..53) for [this] date: weeks start on
|
||||||
|
* Monday and week 1 is the one containing the year's first Thursday. This is the
|
||||||
|
* calendar-week number shown in week/month grids.
|
||||||
|
*
|
||||||
|
* Centralised here so every surface agrees on one scheme — "week number" has
|
||||||
|
* several incompatible definitions (ISO vs. locale-first-day vs. US), and the
|
||||||
|
* ISO year a date belongs to can differ from its calendar year around New Year
|
||||||
|
* (e.g. 2018-12-31 is week 1 of 2019; 2021-01-01 is week 53 of 2020).
|
||||||
|
*/
|
||||||
|
fun LocalDate.isoWeekNumber(): Int = get(IsoFields.WEEK_OF_WEEK_BASED_YEAR)
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package de.jeanlucmakiola.floret.time
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
class IsoWeekTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `first Thursday's week is week 1`() {
|
||||||
|
// 2026-01-01 is a Thursday → ISO week 1.
|
||||||
|
assertThat(LocalDate.of(2026, 1, 1).isoWeekNumber()).isEqualTo(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `mid-year week`() {
|
||||||
|
assertThat(LocalDate.of(2026, 6, 15).isoWeekNumber()).isEqualTo(25)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `late-December days can roll into week 1 of the next ISO year`() {
|
||||||
|
// 2018-12-31 (Mon) belongs to ISO week 1 of 2019.
|
||||||
|
assertThat(LocalDate.of(2018, 12, 31).isoWeekNumber()).isEqualTo(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `early-January days can belong to the last week of the previous ISO year`() {
|
||||||
|
// 2021-01-01 (Fri) belongs to ISO week 53 of 2020.
|
||||||
|
assertThat(LocalDate.of(2021, 1, 1).isoWeekNumber()).isEqualTo(53)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a 53-week year reports week 53`() {
|
||||||
|
// 2020-12-31 (Thu) is ISO week 53.
|
||||||
|
assertThat(LocalDate.of(2020, 12, 31).isoWeekNumber()).isEqualTo(53)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ divergent or app-specific code where it lives.
|
|||||||
| Module | Type | Consumers |
|
| Module | Type | Consumers |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `core-time` | JVM | Agendula (and Calendula's pending branch) |
|
| `core-time` | JVM | Agendula (and Calendula's pending branch) |
|
||||||
|
| `core-reminders` | JVM | Agendula (and Calendula's pending branch) |
|
||||||
| `core-crash` | Android | Agendula |
|
| `core-crash` | Android | Agendula |
|
||||||
| `identity` | Android | Agendula |
|
| `identity` | Android | Agendula |
|
||||||
| `components` | Android | Agendula |
|
| `components` | Android | Agendula |
|
||||||
@@ -47,11 +48,14 @@ See [`../CHANGELOG.md`](../CHANGELOG.md) for the contents of each.
|
|||||||
captured instead at the **component** level (shared `GroupedRow`/section
|
captured instead at the **component** level (shared `GroupedRow`/section
|
||||||
primitives + `CollapsingScaffold` + `OptionPicker`); each app composes its own
|
primitives + `CollapsingScaffold` + `OptionPicker`); each app composes its own
|
||||||
settings from those.
|
settings from those.
|
||||||
- **`core-reminders`** (unified) — Agendula self-schedules alarms (pull),
|
- **The reminder *scheduler*** — the shared `core-reminders` module is the
|
||||||
Calendula is provider-event-driven (push). Opposite architectures over
|
app-agnostic override *model* only (`ReminderOverride` + codec + unit model,
|
||||||
incompatible data models; forcing a shared abstraction would be leaky. A tiny
|
now multi-value). The *delivery* stays app-local: Agendula self-schedules
|
||||||
`core-notification` (idempotent channel setup, `POST_NOTIFICATIONS` checks)
|
alarms (pull), Calendula is provider-event-driven (push) — opposite
|
||||||
*might* land later; schedulers stay app-local.
|
architectures over incompatible data models, so forcing a shared scheduler
|
||||||
|
abstraction would be leaky. A tiny `core-notification` (idempotent channel
|
||||||
|
setup, `POST_NOTIFICATIONS` checks) *might* land later; schedulers stay
|
||||||
|
app-local.
|
||||||
- Domain-specific UI (date/time fields tied to a domain, calendar/timeline
|
- Domain-specific UI (date/time fields tied to a domain, calendar/timeline
|
||||||
widgets, recurrence text, domain pickers) and all string wording — these encode
|
widgets, recurrence text, domain pickers) and all string wording — these encode
|
||||||
each app's model and resist parameterization.
|
each app's model and resist parameterization.
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ package de.jeanlucmakiola.floret.identity
|
|||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import androidx.activity.BackEventCompat
|
import androidx.activity.BackEventCompat
|
||||||
import androidx.activity.compose.PredictiveBackHandler
|
import androidx.activity.compose.PredictiveBackHandler
|
||||||
|
import androidx.compose.animation.animateContentSize
|
||||||
import androidx.compose.animation.core.Animatable
|
import androidx.compose.animation.core.Animatable
|
||||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -14,6 +17,7 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.IntSize
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import kotlin.coroutines.cancellation.CancellationException
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
|
||||||
@@ -36,6 +40,21 @@ fun rememberReduceMotion(): Boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Animates a composable's own size changes with the family's spatial motion, so
|
||||||
|
* a block that grows or shrinks — a text field wrapping to a new line, a value
|
||||||
|
* swapped for a longer one — eases into its new height instead of jumping. Wraps
|
||||||
|
* Compose's [androidx.compose.animation.animateContentSize] onto the active M3
|
||||||
|
* Expressive motion scheme; under reduced motion the new size snaps in.
|
||||||
|
*
|
||||||
|
* Apply it high in the modifier chain of the element whose height changes.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun Modifier.animateContentSizeMotion(reduceMotion: Boolean = rememberReduceMotion()): Modifier =
|
||||||
|
if (reduceMotion) this
|
||||||
|
else animateContentSize(animationSpec = MaterialTheme.motionScheme.fastSpatialSpec<IntSize>())
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The standard Android predictive-back transform for a full-screen surface: as
|
* 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
|
* the back gesture is dragged, the surface scales toward ~90%, shifts toward the
|
||||||
|
|||||||
Reference in New Issue
Block a user