From 8b33635a852d404cd72d2483dd5bdc4926f13276 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 7 Oct 2026 09:36:15 +0200 Subject: [PATCH] Converge sibling branches into main (0.7.0) (#11) Brings the branches the sibling apps actually ship from back onto main, so floret-kit stops forking per project. Merged in: - `vouch/settings` (what vouch pins), which already contains `feat/clockula` (what clockula pins) and `chore/deps-2026-10`: core-prefs, core-di, CollapsingScaffold FAB/no-back slots, the glance module, LocaleManager on API 33+, and the dependency bump - `feat/glance-module`: the three glance commits clockula's branch didn't have yet (progress ring/tile/check button, expressive shapes + header pill, WidgetDoneState) Both merged without conflicts. Version ends up at 0.7.0 (untagged). Heads-up for consumers: the deps bump moves the toolchain to Gradle 9.8.0 / AGP 9.4.1 / Kotlin 2.4.20. Since floret-kit is a composite build, a consumer must be on Gradle >= 9.6 to pin anything from here on. Calendula builds, tests and lints against this branch with just that toolchain bump, no source changes. Co-authored-by: Jean-Luc Makiola Reviewed-on: https://codeberg.org/jlmakiola/floret-kit/pulls/11 --- CHANGELOG.md | 97 +++++- CONTRIBUTING.md | 8 +- README.md | 8 +- build.gradle.kts | 3 +- .../floret/components/CollapsingScaffold.kt | 44 ++- .../floret/components/SettingsRecipes.kt | 4 +- core-di/build.gradle.kts | 39 +++ .../floret/di/CoroutinesModule.kt | 32 ++ .../de/jeanlucmakiola/floret/di/Qualifiers.kt | 21 ++ .../floret/di/CoroutinesModuleTest.kt | 61 ++++ .../floret/di/QualifiersTest.kt | 68 +++++ .../floret/locale/AppLanguage.kt | 38 ++- core-prefs/build.gradle.kts | 55 ++++ .../jeanlucmakiola/floret/prefs/Appearance.kt | 40 +++ .../jeanlucmakiola/floret/prefs/EnumPrefs.kt | 27 ++ .../de/jeanlucmakiola/floret/prefs/Pref.kt | 84 ++++++ .../jeanlucmakiola/floret/prefs/PrefStore.kt | 48 +++ .../floret/prefs/AppearancePrefsTest.kt | 96 ++++++ .../floret/prefs/EnumParsingTest.kt | 65 ++++ .../floret/prefs/PrefStoreTest.kt | 284 ++++++++++++++++++ .../jeanlucmakiola/floret/prefs/PrefTest.kt | 71 +++++ .../floret/prefs/ThemeModeTest.kt | 51 ++++ docs/ARCHITECTURE.md | 16 +- docs/README.md | 3 +- docs/ROADMAP.md | 20 +- glance/build.gradle.kts | 63 ++++ .../floret/glance/FloretGlanceTheme.kt | 45 +++ .../glance/FloretWidgetUpdateReceiver.kt | 72 +++++ .../floret/glance/WidgetCheckButton.kt | 51 ++++ .../floret/glance/WidgetDoneState.kt | 59 ++++ .../floret/glance/WidgetGroupedRow.kt | 58 ++++ .../floret/glance/WidgetHeader.kt | 184 ++++++++++++ .../floret/glance/WidgetMessage.kt | 43 +++ .../floret/glance/WidgetProgressRing.kt | 79 +++++ .../floret/glance/WidgetRollover.kt | 79 +++++ .../floret/glance/WidgetShape.kt | 67 +++++ .../floret/glance/WidgetSurface.kt | 60 ++++ .../floret/glance/WidgetTile.kt | 42 +++ .../res/drawable/floret_ic_widget_add.xml | 6 + .../res/drawable/floret_ic_widget_check.xml | 10 + .../drawable/floret_ic_widget_checklist.xml | 11 + .../res/drawable/floret_widget_row_alone.xml | 6 + .../res/drawable/floret_widget_row_bottom.xml | 10 + .../res/drawable/floret_widget_row_middle.xml | 6 + .../res/drawable/floret_widget_row_top.xml | 10 + .../drawable/floret_widget_shape_cookie.xml | 7 + .../drawable/floret_widget_shape_sunny.xml | 7 + .../glance/FloretWidgetUpdateReceiverTest.kt | 51 ++++ .../floret/glance/WidgetRolloverTest.kt | 145 +++++++++ gradle/libs.versions.toml | 37 ++- gradle/wrapper/gradle-wrapper.jar | Bin 43764 -> 47623 bytes gradle/wrapper/gradle-wrapper.properties | 6 +- gradlew | 11 +- gradlew.bat | 86 +++--- settings.gradle.kts | 3 + 55 files changed, 2517 insertions(+), 80 deletions(-) create mode 100644 core-di/build.gradle.kts create mode 100644 core-di/src/main/kotlin/de/jeanlucmakiola/floret/di/CoroutinesModule.kt create mode 100644 core-di/src/main/kotlin/de/jeanlucmakiola/floret/di/Qualifiers.kt create mode 100644 core-di/src/test/kotlin/de/jeanlucmakiola/floret/di/CoroutinesModuleTest.kt create mode 100644 core-di/src/test/kotlin/de/jeanlucmakiola/floret/di/QualifiersTest.kt create mode 100644 core-prefs/build.gradle.kts create mode 100644 core-prefs/src/main/kotlin/de/jeanlucmakiola/floret/prefs/Appearance.kt create mode 100644 core-prefs/src/main/kotlin/de/jeanlucmakiola/floret/prefs/EnumPrefs.kt create mode 100644 core-prefs/src/main/kotlin/de/jeanlucmakiola/floret/prefs/Pref.kt create mode 100644 core-prefs/src/main/kotlin/de/jeanlucmakiola/floret/prefs/PrefStore.kt create mode 100644 core-prefs/src/test/kotlin/de/jeanlucmakiola/floret/prefs/AppearancePrefsTest.kt create mode 100644 core-prefs/src/test/kotlin/de/jeanlucmakiola/floret/prefs/EnumParsingTest.kt create mode 100644 core-prefs/src/test/kotlin/de/jeanlucmakiola/floret/prefs/PrefStoreTest.kt create mode 100644 core-prefs/src/test/kotlin/de/jeanlucmakiola/floret/prefs/PrefTest.kt create mode 100644 core-prefs/src/test/kotlin/de/jeanlucmakiola/floret/prefs/ThemeModeTest.kt create mode 100644 glance/build.gradle.kts create mode 100644 glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/FloretGlanceTheme.kt create mode 100644 glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/FloretWidgetUpdateReceiver.kt create mode 100644 glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetCheckButton.kt create mode 100644 glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetDoneState.kt create mode 100644 glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetGroupedRow.kt create mode 100644 glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetHeader.kt create mode 100644 glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetMessage.kt create mode 100644 glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetProgressRing.kt create mode 100644 glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetRollover.kt create mode 100644 glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetShape.kt create mode 100644 glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetSurface.kt create mode 100644 glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetTile.kt create mode 100644 glance/src/main/res/drawable/floret_ic_widget_add.xml create mode 100644 glance/src/main/res/drawable/floret_ic_widget_check.xml create mode 100644 glance/src/main/res/drawable/floret_ic_widget_checklist.xml create mode 100644 glance/src/main/res/drawable/floret_widget_row_alone.xml create mode 100644 glance/src/main/res/drawable/floret_widget_row_bottom.xml create mode 100644 glance/src/main/res/drawable/floret_widget_row_middle.xml create mode 100644 glance/src/main/res/drawable/floret_widget_row_top.xml create mode 100644 glance/src/main/res/drawable/floret_widget_shape_cookie.xml create mode 100644 glance/src/main/res/drawable/floret_widget_shape_sunny.xml create mode 100644 glance/src/test/kotlin/de/jeanlucmakiola/floret/glance/FloretWidgetUpdateReceiverTest.kt create mode 100644 glance/src/test/kotlin/de/jeanlucmakiola/floret/glance/WidgetRolloverTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 025fe57..8894c4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,105 @@ All notable changes to floret-kit are documented here. The format follows floret-kit is consumed **from source** — each app embeds it as a git submodule and builds it through a Gradle composite build, pinning a specific commit. There are no binary releases; "version" tracks the shared `version` in the root build -(currently `0.4.0`). Entries are grouped by module. +(currently `0.7.0`). Entries are grouped by module. ## [Unreleased] +### Fixed + +- **`core-locale`** — picking a language did nothing on Android 13+. AppCompat's + `setApplicationLocales` only reaches the platform through a live + `AppCompatActivity`, and every app uses `ComponentActivity`. `AppLanguage` + now calls `LocaleManager` directly there: `apply(context, tag)` and + `currentTag(context[, supported])`. The context-free versions are deprecated. + `LanguagePickerRow` uses the new ones; apps that call `AppLanguage` themselves + (Agendula's settings row) need to pass a context. + +### Changed + +- **Build** — dependencies to the newest stable releases: AGP 9.4.1, Kotlin + 2.4.20, Gradle 9.8.0, Compose BOM 2026.09.00, core-ktx 1.19.1, appcompat + 1.8.0, JUnit 6.1.3; material3 to `1.5.0-alpha29` (still no stable 1.5 with the + Expressive APIs). AGP must match across a composite build, so a consuming app + re-pinning to this needs AGP 9.4.1 itself. + +### Added + +- **`glance`** (new module) — the family's shared home-screen-widget plumbing + and M3 Expressive widget vocabulary, built on Jetpack Glance: + `FloretGlanceTheme` (dynamic colour on API 31+, app-supplied fallback + schemes otherwise, a per-widget `WidgetThemeMode` override), `WidgetSurface` + (solid/tinted/transparent root backgrounds with the system corner radius), + `WidgetHeader` + `WidgetActionButton` (a 48dp tonal/filled pill, extendable + with a label), `WidgetGroupedRow` (the Glance counterpart of `components`' + `GroupedSurface`, built from four position-shaped drawables since Glance's + `cornerRadius` has no per-corner overload), `WidgetMessage`, and the + `WidgetRollover` + `FloretWidgetUpdateReceiver` midnight-rollover plumbing. + Converged from Agendula's widget package, written for the M3 Expressive + widget pass (calendula#71); Calendula adopts it when its own widget overhaul + lands. + +## [0.7.0] - 2026-09-12 + +### Added + +- **`components`** — `CollapsingScaffold` takes `floatingActionButton` and + `floatingActionButtonPosition`, forwarded to the underlying `Scaffold`. Both + default to the bare scaffold, so every existing call site in every app is + unchanged, and they sit after `snackbarHost` so no positional call shifts. + The driver is Clockula's alarms tab: three of its four tabs need an "add" + affordance on a scaffold the kit owns, and the alternative — a `Box` wrapping + the scaffold with a bottom-end FAB — re-derives the window-inset and + position maths `Scaffold` already does, once per tab and again in every + sibling app. + +## [0.6.0] - 2026-09-11 + +### Changed + +- **`components`** — `CollapsingScaffold`'s `onBack` is now nullable and + defaults to null. A null `onBack` draws no navigation icon and wires no + predictive-back handler; every existing call site, which passes a lambda, + is unchanged. The driver is Clockula's four tabs: a top-level destination + wants exactly this scaffold — the large title that shrinks into the bar over + a scrolling column — and nothing behind it to go back to, and a back arrow + that pops nothing is worse than no arrow at all. Cheaper and truer than each + app hand-rolling a second, arrow-less scaffold beside this one. + +## [0.5.0] - 2026-09-11 + +### Added + +- **`core-prefs`** — the family's typed DataStore layer, extracted now that + Clockula is the third app that would have written it again. `Pref` is one + preference as data — its on-disk key name, its default and its codec — built + by `booleanPref`/`intPref`/`longPref`/`floatPref`/`stringPref`/`stringSetPref`, + the nullable pair (`nullableStringPref`/`nullableLongPref`, where writing + `null` *removes* the key rather than storing a sentinel), `enumPref`, and + `map()` for a view of the same slot as another type. `PrefStore` wraps a + `DataStore` with `get`/`set`/`update`/`clear`/`clearAll`, a raw + `edit` escape hatch, and `flow(pref)` — which is `distinctUntilChanged`, so an + unrelated key's write no longer re-emits every screen's setting, the bug both + siblings' hand-rolled `data.map { … }` carried. `String?.toEnum(default)` is + the read path for every enum-valued preference: unknown, empty and wrong-case + values degrade to the default instead of throwing. Also the shared appearance + slice — `ThemeMode` (with `isDark(systemDark)`, the `when` block that was + duplicated in both siblings' `MainActivity`), `Appearance` and + `AppearancePrefs`. The stored key names are byte-identical to what Calendula + and Agendula already write (`theme_mode`, `dynamic_color`), so adopting the + module is a code change with **no data migration**. The store's *name* and its + construction stay with the app. Consumed by Clockula. + +- **`core-di`** — the family's shared DI vocabulary: the `@IoDispatcher`, + `@DefaultDispatcher` and `@ApplicationScope` qualifiers and the + `CoroutinesModule` that provides them (the application scope on a + `SupervisorJob`, so one failing collector can't take its siblings down). Every + app in the family had re-declared the same qualifier and the same provider. + The kit's only module that knows about Dagger, and its only one carrying an + annotation processor — a `@Module @InstallIn(...)` reaches a consuming app's + component only if the library itself is run through `hilt-compiler`. Consumed + by Clockula. + ## [0.4.0] - 2026-09-24 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 041890b..684959a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,6 +36,8 @@ practical how. ```sh ./gradlew :core-time:test # JVM module (JUnit 5 + Truth) ./gradlew :core-crash:testDebugUnitTest +./gradlew :core-prefs:testDebugUnitTest +./gradlew :core-di:test ./gradlew :identity:assembleDebug ./gradlew build # everything ``` @@ -50,7 +52,11 @@ source. [`settings.gradle.kts`](settings.gradle.kts). 2. Add a `/build.gradle.kts`: - **JVM:** `plugins { alias(libs.plugins.kotlin.jvm) }`, a `kotlin { compilerOptions { jvmTarget = JVM_17 } }` block, JUnit 5 test deps. - - **Android:** `plugins { alias(libs.plugins.android.library); alias(libs.plugins.kotlin.compose) }` (no `kotlin.android` — AGP 9.x is built-in), `namespace = "de.jeanlucmakiola.floret."`, `compileSdk 37` / `minSdk 29`, `jvmTarget = JVM_17`. + - **Android:** `plugins { alias(libs.plugins.android.library); alias(libs.plugins.kotlin.compose) }` (no `kotlin.android` — AGP 9.x is built-in), `namespace = "de.jeanlucmakiola.floret."`, `compileSdk 37` / `minSdk 29`, `jvmTarget = JVM_17`. An Android module with no + composables in it (e.g. `core-prefs`) applies only `android.library`. + - **Needs an annotation processor?** Add `alias(libs.plugins.ksp)` and declare + the processor with `ksp(...)` — see `core-di`, whose Hilt `@Module` has to be + processed in the library for a consuming app's component to see it. 3. Put sources under `src/main/kotlin/de/jeanlucmakiola/floret//`. The root build's `subprojects {}` sets the shared `group`/`version`, so no per-module coordinates are needed. diff --git a/README.md b/README.md index 1e18014..821e7ef 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,8 @@ The shared **Material 3 Expressive design system + plumbing** for the Floret family of Android apps ([Calendula](https://gitea.jeanlucmakiola.de/makiolaj/calendula), -[Agendula](https://gitea.jeanlucmakiola.de/makiolaj/agendula), and future siblings). +[Agendula](https://gitea.jeanlucmakiola.de/makiolaj/agendula), +[Clockula](https://gitea.jeanlucmakiola.de/makiolaj/clockula), and future siblings). "Floret" is the family's design language: a Calendula flower head is a cluster of many small *florets*, and each app is one of them. This repo is the bloom they @@ -37,9 +38,12 @@ See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the full consumption mode | `core-time` | JVM | Date/time helpers: `DayWindow` local-day windows, locale/zone-aware `Instant` formatting, `TimeBridge` (millis ↔ `Instant`). No Android, no deps. | | `core-reminders` | JVM | Reminder-lead plumbing: the lead-time unit model (`ReminderUnit`, `decomposeReminderMinutes`), the per-target override model (`ReminderOverride` + `reminderLeadFor` / `applyReminderOverride`) and the stored-format codec (`ReminderOverrideCodec`, separators configurable per app). Each app layers its own DataStore + string labels on top. | | `core-locale` | Android | Everything "what language are we in". Per-app language plumbing (`AppLanguage`): read the shipped languages from the app's `res/xml/locales_config.xml`, get/set the applied language via `AppCompatDelegate`, render each language's autonym. Plus `currentLocale()` (observes the applied language from Compose) and `localizedDateFormatter(locale, skeleton)` (lays a field skeleton out in the locale's own order). The app passes its own `locales_config`. | +| `core-prefs` | Android | The typed DataStore layer: `Pref` + `PrefStore` (typed read/write, distinct-until-changed flows), `toEnum()` for values that degrade instead of throwing, and the family's shared appearance prefs (`ThemeMode`, `dynamicColor`). The store's name and construction stay with the app. | +| `core-di` | JVM | The shared DI vocabulary: `@IoDispatcher`, `@DefaultDispatcher`, `@ApplicationScope` and the Hilt module that provides them. | | `core-crash` | Android | Privacy-respecting on-device crash capture + report dialog + issue-tracker hand-off. Parameterized per app via `CrashConfig`. | | `identity` | Android | The M3 Expressive theme factory `FloretExpressiveTheme(…)`, `rememberNavSlideSpec()`, the content transitions (`expandEnter`/`collapseExit`/`itemEnter`/…) and the `predictiveBack` peek. Each app supplies its own seed/palette. | | `components` | Android | Shared Compose vocabulary + recipes: `GroupedSurface`/`GroupedRow`, `InlineTextField`, `OptionCard`, `CollapsingScaffold`, `OptionPicker`, `ReorderableColumn`, `DebugRibbon`, `pastelize()`, `DialogControls`, `OnboardingScaffold`, `OptionalFormSection`, `AboutCard`, `LanguagePickerRow`, `SnackChip`. | +| `glance` | Android | Home-screen-widget plumbing on Jetpack Glance: `FloretGlanceTheme`, the rounded `WidgetSurface` (solid/tinted/transparent backgrounds), `WidgetHeader`/`WidgetActionButton`, the position-aware `WidgetGroupedRow`, `WidgetMessage`, `WidgetRollover` (the midnight-rollover alarm) and `FloretWidgetUpdateReceiver`. App supplies its own colour schemes, data and receiver glue. | _The principle: the **mechanics** are shared, the **look** stays per-app. See [`docs/ROADMAP.md`](docs/ROADMAP.md) for what's extracted, deferred, and @@ -57,5 +61,7 @@ See [`docs/`](docs/) — start with [`docs/README.md`](docs/README.md) ```sh ./gradlew :core-time:test # JVM module tests ./gradlew :core-crash:testDebugUnitTest +./gradlew :core-prefs:testDebugUnitTest +./gradlew :core-di:test ./gradlew build # everything (Android modules need an SDK; see above) ``` diff --git a/build.gradle.kts b/build.gradle.kts index 637e3db..908c79f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,9 +6,10 @@ plugins { alias(libs.plugins.android.library) apply false alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.kotlin.jvm) apply false + alias(libs.plugins.ksp) apply false } subprojects { group = "de.jeanlucmakiola.floret" - version = "0.4.0" + version = "0.7.0" } diff --git a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/CollapsingScaffold.kt b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/CollapsingScaffold.kt index 345f16f..ecd66ea 100644 --- a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/CollapsingScaffold.kt +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/CollapsingScaffold.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FabPosition import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LargeTopAppBar @@ -44,10 +45,17 @@ import de.jeanlucmakiola.floret.identity.predictiveBack * * 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, with [visible] - * passed through to [Modifier.predictiveBack] for a surface that is shown and - * hidden in place. + * transient messages; [floatingActionButton] (with + * [floatingActionButtonPosition]) places a FAB the underlying [Scaffold] + * positions and insets — for a list destination whose primary action is "add"; + * [predictiveBack] (off by default) wires the gesture preview to [onBack] for + * full-screen surfaces that want it, with [visible] passed through to + * [Modifier.predictiveBack] for a surface that is shown and hidden in place. + * + * [onBack] is optional. Pass null for a **top-level destination** — a tab in an + * app's navigation bar, say — which has nothing to go back to: no navigation + * icon is drawn, and [predictiveBack] wires nothing, so the gesture falls + * through to whoever owns the back stack above. * * Set [scrollable] to false when [content] brings its own scrolling container. * The default column scrolls as a whole, which composes every row up front — @@ -63,7 +71,7 @@ import de.jeanlucmakiola.floret.identity.predictiveBack @Composable fun CollapsingScaffold( title: String, - onBack: () -> Unit, + onBack: (() -> Unit)? = null, modifier: Modifier = Modifier, largeTopBar: Boolean = true, predictiveBack: Boolean = false, @@ -71,6 +79,8 @@ fun CollapsingScaffold( scrollable: Boolean = true, actions: @Composable RowScope.() -> Unit = {}, snackbarHost: @Composable () -> Unit = {}, + floatingActionButton: @Composable () -> Unit = {}, + floatingActionButtonPosition: FabPosition = FabPosition.End, content: @Composable ColumnScope.() -> Unit, ) { val scrollBehavior = if (largeTopBar) { @@ -80,17 +90,27 @@ fun CollapsingScaffold( } Scaffold( modifier = modifier - .then(if (predictiveBack) Modifier.predictiveBack(onBack = onBack, visible = visible) else Modifier) + .then( + if (predictiveBack && onBack != null) { + Modifier.predictiveBack(onBack = onBack, visible = visible) + } else { + Modifier + }, + ) .fillMaxSize() .background(MaterialTheme.colorScheme.surface) .nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { val navigationIcon = @Composable { - IconButton(onClick = onBack) { - Icon( - Icons.AutoMirrored.Rounded.ArrowBack, - contentDescription = stringResource(R.string.back), - ) + // A top-level destination has nothing to go back to, so a null + // [onBack] draws no arrow at all rather than an arrow that lies. + if (onBack != null) { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(R.string.back), + ) + } } } if (largeTopBar) { @@ -116,6 +136,8 @@ fun CollapsingScaffold( } }, snackbarHost = snackbarHost, + floatingActionButton = floatingActionButton, + floatingActionButtonPosition = floatingActionButtonPosition, ) { innerPadding -> Column( modifier = Modifier diff --git a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/SettingsRecipes.kt b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/SettingsRecipes.kt index c7055ee..3f82d9a 100644 --- a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/SettingsRecipes.kt +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/SettingsRecipes.kt @@ -123,7 +123,7 @@ fun LanguagePickerRow( ) { val context = LocalContext.current val supported = remember { AppLanguage.supportedTags(context, localesConfig) } - var current by remember { mutableStateOf(AppLanguage.currentTag(supported)) } + var current by remember { mutableStateOf(AppLanguage.currentTag(context, supported)) } var showDialog by remember { mutableStateOf(false) } // null = follow the system; the rest are BCP-47 tags from locales_config.xml. val options = remember(supported) { listOf(null) + supported } @@ -145,7 +145,7 @@ fun LanguagePickerRow( label = { label(it) }, onSelect = { current = it - AppLanguage.apply(it) + AppLanguage.apply(context, it) }, onDismiss = { showDialog = false }, ) diff --git a/core-di/build.gradle.kts b/core-di/build.gradle.kts new file mode 100644 index 0000000..dc57a34 --- /dev/null +++ b/core-di/build.gradle.kts @@ -0,0 +1,39 @@ +// core-di — the family's shared DI vocabulary: the `@IoDispatcher`, +// `@DefaultDispatcher` and `@ApplicationScope` qualifiers plus the Hilt module +// that provides them. A plain JVM library (nothing here touches Android), but +// the one kit module that carries an annotation processor: a `@Module +// @InstallIn(...)` only reaches a consuming app's component if the *library* +// itself is run through `hilt-compiler`, which generates the `@AggregatedDeps` +// metadata the app's processor reads off the classpath. +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.ksp) +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +dependencies { + // `CoroutineDispatcher` / `CoroutineScope` are returned from the module. + api(libs.kotlinx.coroutines.core) + implementation(libs.hilt.core) + ksp(libs.hilt.compiler) + + testImplementation(libs.junit.jupiter.api) + testRuntimeOnly(libs.junit.jupiter.engine) + testRuntimeOnly(libs.junit.platform.launcher) + testImplementation(libs.truth) + testImplementation(libs.kotlinx.coroutines.test) +} + +tasks.test { + useJUnitPlatform() +} diff --git a/core-di/src/main/kotlin/de/jeanlucmakiola/floret/di/CoroutinesModule.kt b/core-di/src/main/kotlin/de/jeanlucmakiola/floret/di/CoroutinesModule.kt new file mode 100644 index 0000000..a3173d3 --- /dev/null +++ b/core-di/src/main/kotlin/de/jeanlucmakiola/floret/di/CoroutinesModule.kt @@ -0,0 +1,32 @@ +package de.jeanlucmakiola.floret.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import javax.inject.Singleton + +/** Provides the family's dispatchers and the application-lifetime scope. */ +@Module +@InstallIn(SingletonComponent::class) +object CoroutinesModule { + + @Provides + @IoDispatcher + fun ioDispatcher(): CoroutineDispatcher = Dispatchers.IO + + @Provides + @DefaultDispatcher + fun defaultDispatcher(): CoroutineDispatcher = Dispatchers.Default + + /** SupervisorJob, so one failing collector can't take the others down with it. */ + @Provides + @Singleton + @ApplicationScope + fun applicationScope(@DefaultDispatcher dispatcher: CoroutineDispatcher): CoroutineScope = + CoroutineScope(SupervisorJob() + dispatcher) +} diff --git a/core-di/src/main/kotlin/de/jeanlucmakiola/floret/di/Qualifiers.kt b/core-di/src/main/kotlin/de/jeanlucmakiola/floret/di/Qualifiers.kt new file mode 100644 index 0000000..d53a69e --- /dev/null +++ b/core-di/src/main/kotlin/de/jeanlucmakiola/floret/di/Qualifiers.kt @@ -0,0 +1,21 @@ +package de.jeanlucmakiola.floret.di + +import javax.inject.Qualifier + +/** The IO [kotlinx.coroutines.CoroutineDispatcher] — disk, network, content providers. */ +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class IoDispatcher + +/** The CPU-bound default [kotlinx.coroutines.CoroutineDispatcher]. */ +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class DefaultDispatcher + +/** + * The process-lifetime [kotlinx.coroutines.CoroutineScope] — for work that outlives + * any screen. Never cancelled, so nothing unbounded may be launched in it. + */ +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class ApplicationScope diff --git a/core-di/src/test/kotlin/de/jeanlucmakiola/floret/di/CoroutinesModuleTest.kt b/core-di/src/test/kotlin/de/jeanlucmakiola/floret/di/CoroutinesModuleTest.kt new file mode 100644 index 0000000..27b1e48 --- /dev/null +++ b/core-di/src/test/kotlin/de/jeanlucmakiola/floret/di/CoroutinesModuleTest.kt @@ -0,0 +1,61 @@ +package de.jeanlucmakiola.floret.di + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import kotlin.coroutines.ContinuationInterceptor + +/** What the Hilt module hands out, checked by calling the provider functions directly. */ +class CoroutinesModuleTest { + + @Test + fun `ioDispatcher provides the IO dispatcher`() { + val dispatcher = CoroutinesModule.ioDispatcher() + + assertThat(dispatcher).isSameInstanceAs(Dispatchers.IO) + } + + @Test + fun `defaultDispatcher provides the Default dispatcher`() { + val dispatcher = CoroutinesModule.defaultDispatcher() + + assertThat(dispatcher).isSameInstanceAs(Dispatchers.Default) + } + + @Test + fun `the application scope runs on the dispatcher it was given`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + + val scope = CoroutinesModule.applicationScope(dispatcher) + + assertThat(scope.coroutineContext[ContinuationInterceptor]).isSameInstanceAs(dispatcher) + } + + @Test + fun `a failing child does not take down its sibling`() = runTest { + val scope = CoroutinesModule.applicationScope(UnconfinedTestDispatcher(testScheduler)) + val swallow = CoroutineExceptionHandler { _, _ -> } + var siblingCompleted = false + + scope.launch(swallow) { error("boom") } + scope.launch { siblingCompleted = true } + + assertThat(siblingCompleted && scope.isActive).isTrue() + } + + @Test + fun `the application scope is handed out live`() { + val scope = CoroutinesModule.applicationScope(Dispatchers.Unconfined) + + val job = scope.coroutineContext[Job] + + assertThat(job?.isActive).isTrue() + } +} diff --git a/core-di/src/test/kotlin/de/jeanlucmakiola/floret/di/QualifiersTest.kt b/core-di/src/test/kotlin/de/jeanlucmakiola/floret/di/QualifiersTest.kt new file mode 100644 index 0000000..eb440a7 --- /dev/null +++ b/core-di/src/test/kotlin/de/jeanlucmakiola/floret/di/QualifiersTest.kt @@ -0,0 +1,68 @@ +package de.jeanlucmakiola.floret.di + +import com.google.common.truth.Truth.assertThat +import dagger.Module +import org.junit.jupiter.api.Test +import java.lang.annotation.Retention +import java.lang.annotation.RetentionPolicy +import javax.inject.Qualifier + +/** + * The DI vocabulary itself: an annotation only works as a Dagger qualifier if it + * carries `@Qualifier` and survives into the class file. + */ +class QualifiersTest { + + private val qualifiers = listOf( + IoDispatcher::class.java, + DefaultDispatcher::class.java, + ApplicationScope::class.java, + ) + + @Test + fun `IoDispatcher is a qualifier`() { + val annotation = IoDispatcher::class.java + + val isQualifier = annotation.isAnnotationPresent(Qualifier::class.java) + + assertThat(isQualifier).isTrue() + } + + @Test + fun `DefaultDispatcher is a qualifier`() { + val annotation = DefaultDispatcher::class.java + + val isQualifier = annotation.isAnnotationPresent(Qualifier::class.java) + + assertThat(isQualifier).isTrue() + } + + @Test + fun `ApplicationScope is a qualifier`() { + val annotation = ApplicationScope::class.java + + val isQualifier = annotation.isAnnotationPresent(Qualifier::class.java) + + assertThat(isQualifier).isTrue() + } + + @Test + fun `every qualifier is retained in the class file`() { + val retentions = qualifiers.map { it.getAnnotation(Retention::class.java)?.value } + + assertThat(retentions).containsExactly( + RetentionPolicy.CLASS, + RetentionPolicy.CLASS, + RetentionPolicy.CLASS, + ) + } + + @Test + fun `CoroutinesModule is a Dagger module`() { + val module = CoroutinesModule::class.java + + val isModule = module.isAnnotationPresent(Module::class.java) + + assertThat(isModule).isTrue() + } +} diff --git a/core-locale/src/main/kotlin/de/jeanlucmakiola/floret/locale/AppLanguage.kt b/core-locale/src/main/kotlin/de/jeanlucmakiola/floret/locale/AppLanguage.kt index 8b1efeb..854814c 100644 --- a/core-locale/src/main/kotlin/de/jeanlucmakiola/floret/locale/AppLanguage.kt +++ b/core-locale/src/main/kotlin/de/jeanlucmakiola/floret/locale/AppLanguage.kt @@ -1,6 +1,9 @@ package de.jeanlucmakiola.floret.locale +import android.app.LocaleManager import android.content.Context +import android.os.Build +import android.os.LocaleList import androidx.annotation.XmlRes import androidx.appcompat.app.AppCompatDelegate import androidx.core.os.LocaleListCompat @@ -18,10 +21,12 @@ private const val ANDROID_NS = "http://schemas.android.com/apk/res/android" * 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. + * On API 33+ this talks to the platform [LocaleManager] directly. AppCompat's + * `setApplicationLocales` only reaches it through a live `AppCompatActivity`, so + * from a plain `ComponentActivity` it silently did nothing. Below 33 it falls back + * to the appcompat backport (manifest `autoStoreLocales` service), which only + * applies to AppCompat activities. Setting a locale recreates the activity, which + * re-reads the current value for the picker. */ object AppLanguage { @@ -52,6 +57,15 @@ object AppLanguage { } /** The applied app language as a BCP-47 tag, or `null` when following the system. */ + fun currentTag(context: Context): String? { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val locales = context.getSystemService(LocaleManager::class.java).applicationLocales + return if (locales.isEmpty) null else locales[0].toLanguageTag() + } + return currentTag() + } + + @Deprecated("No-op on API 33+ without an AppCompatActivity", ReplaceWith("currentTag(context)")) fun currentTag(): String? { val locales = AppCompatDelegate.getApplicationLocales() return if (locales.isEmpty) null else locales[0]?.toLanguageTag() @@ -66,6 +80,11 @@ object AppLanguage { * ICU and fills in the implied script, so a "zh-CN" choice reads back as * "zh-Hans-CN" and no longer string-matches the tag the picker offers. */ + fun currentTag(context: Context, supported: List): String? = + resolveTag(currentTag(context), supported) + + @Deprecated("No-op on API 33+ without an AppCompatActivity", ReplaceWith("currentTag(context, supported)")) + @Suppress("DEPRECATION") fun currentTag(supported: List): String? = resolveTag(currentTag(), supported) /** @@ -87,6 +106,17 @@ object AppLanguage { } /** Apply a BCP-47 tag, or `null` to follow the system languages. */ + fun apply(context: Context, tag: String?) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.getSystemService(LocaleManager::class.java).applicationLocales = + LocaleList.forLanguageTags(tag.orEmpty()) + return + } + @Suppress("DEPRECATION") + apply(tag) + } + + @Deprecated("No-op on API 33+ without an AppCompatActivity", ReplaceWith("apply(context, tag)")) fun apply(tag: String?) { val locales = if (tag == null) { LocaleListCompat.getEmptyLocaleList() diff --git a/core-prefs/build.gradle.kts b/core-prefs/build.gradle.kts new file mode 100644 index 0000000..b222104 --- /dev/null +++ b/core-prefs/build.gradle.kts @@ -0,0 +1,55 @@ +// core-prefs — the family's typed DataStore layer: `Pref` (a key + a default + +// its codec), `PrefStore` (typed, distinct-until-changed reads and writes over a +// `DataStore`), `toEnum()` for stored values that degrade instead of +// throwing, and the shared appearance preferences (`ThemeMode`, `dynamicColor`). +// The store's *name* and its construction stay with the consuming app. +// +// An Android library rather than a JVM one: the whole surface is typed over +// `androidx.datastore.preferences.core.DataStore` and the app needs +// `Context.preferencesDataStoreFile(...)`, which ships only in the Android +// artifact — so this module `api`-exposes it and an app gets the whole stack from +// one dependency. No Compose here, so only the Android library plugin is applied. +plugins { + alias(libs.plugins.android.library) +} + +android { + namespace = "de.jeanlucmakiola.floret.prefs" + 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 { + // Both are in the module's public API: `DataStore` is a + // constructor parameter and `Flow` is a return type. + api(libs.androidx.datastore.preferences) + api(libs.kotlinx.coroutines.core) + + testImplementation(libs.junit.jupiter.api) + testRuntimeOnly(libs.junit.jupiter.engine) + testRuntimeOnly(libs.junit.platform.launcher) + testImplementation(libs.truth) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.turbine) +} diff --git a/core-prefs/src/main/kotlin/de/jeanlucmakiola/floret/prefs/Appearance.kt b/core-prefs/src/main/kotlin/de/jeanlucmakiola/floret/prefs/Appearance.kt new file mode 100644 index 0000000..dcb0c22 --- /dev/null +++ b/core-prefs/src/main/kotlin/de/jeanlucmakiola/floret/prefs/Appearance.kt @@ -0,0 +1,40 @@ +package de.jeanlucmakiola.floret.prefs + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map + +/** Light/dark override. [SYSTEM] follows the device setting. */ +enum class ThemeMode { SYSTEM, LIGHT, DARK } + +/** Resolve to a concrete dark flag; [SYSTEM] defers to [systemDark]. */ +fun ThemeMode.isDark(systemDark: Boolean): Boolean = when (this) { + ThemeMode.SYSTEM -> systemDark + ThemeMode.LIGHT -> false + ThemeMode.DARK -> true +} + +/** The two appearance choices every app in the family has. */ +data class Appearance( + val themeMode: ThemeMode = ThemeMode.SYSTEM, + val dynamicColor: Boolean = true, +) + +/** + * The family's shared appearance preferences. The key names are fixed at the + * values Calendula and Agendula already write — changing one is a data migration. + */ +object AppearancePrefs { + val themeMode: Pref = enumPref("theme_mode", ThemeMode.SYSTEM) + val dynamicColor: Pref = booleanPref("dynamic_color", true) +} + +/** Both appearance choices as one distinct-until-changed stream. */ +fun PrefStore.appearance(): Flow = data + .map { + Appearance( + themeMode = AppearancePrefs.themeMode.read(it), + dynamicColor = AppearancePrefs.dynamicColor.read(it), + ) + } + .distinctUntilChanged() diff --git a/core-prefs/src/main/kotlin/de/jeanlucmakiola/floret/prefs/EnumPrefs.kt b/core-prefs/src/main/kotlin/de/jeanlucmakiola/floret/prefs/EnumPrefs.kt new file mode 100644 index 0000000..4f3c151 --- /dev/null +++ b/core-prefs/src/main/kotlin/de/jeanlucmakiola/floret/prefs/EnumPrefs.kt @@ -0,0 +1,27 @@ +package de.jeanlucmakiola.floret.prefs + +import androidx.datastore.preferences.core.stringPreferencesKey + +/** Stored [Enum.name] -> entry; `null`, unknown and wrong-case fall back to [default]. */ +fun > String?.toEnum(entries: Collection, default: E): E = + entries.firstOrNull { it.name == this } ?: default + +/** Stored [Enum.name] -> entry; `null`, unknown and wrong-case fall back to [default]. */ +inline fun > String?.toEnum(default: E): E = + toEnum(enumValues().toList(), default) + +/** An enum stored by [Enum.name]; an unreadable value degrades to [default] rather than throwing. */ +fun > enumPref(name: String, default: E, entries: Collection): Pref { + val key = stringPreferencesKey(name) + return Pref( + name = name, + default = default, + key = key, + read = { it[key].toEnum(entries, default) }, + write = { preferences, value -> preferences[key] = value.name }, + ) +} + +/** An enum stored by [Enum.name]; an unreadable value degrades to [default] rather than throwing. */ +inline fun > enumPref(name: String, default: E): Pref = + enumPref(name, default, enumValues().toList()) diff --git a/core-prefs/src/main/kotlin/de/jeanlucmakiola/floret/prefs/Pref.kt b/core-prefs/src/main/kotlin/de/jeanlucmakiola/floret/prefs/Pref.kt new file mode 100644 index 0000000..785660b --- /dev/null +++ b/core-prefs/src/main/kotlin/de/jeanlucmakiola/floret/prefs/Pref.kt @@ -0,0 +1,84 @@ +package de.jeanlucmakiola.floret.prefs + +import androidx.datastore.preferences.core.MutablePreferences +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.floatPreferencesKey +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.core.stringSetPreferencesKey + +/** + * One typed preference: its on-disk key name, its default, and the pair of + * functions that read it out of / write it into a [Preferences] snapshot. + * Instances come from the factory functions below or from [map]; the + * constructor is internal so the stored representation stays this module's. + */ +class Pref internal constructor( + val name: String, + val default: T, + internal val key: Preferences.Key<*>, + internal val read: (Preferences) -> T, + internal val write: (MutablePreferences, T) -> Unit, +) { + /** A view of this preference as [R]; shares the key, derives the default through [decode]. */ + fun map(decode: (T) -> R, encode: (R) -> T): Pref = Pref( + name = name, + default = decode(default), + key = key, + read = { decode(read(it)) }, + write = { preferences, value -> write(preferences, encode(value)) }, + ) +} + +fun booleanPref(name: String, default: Boolean): Pref = + scalarPref(booleanPreferencesKey(name), name, default) + +fun intPref(name: String, default: Int): Pref = + scalarPref(intPreferencesKey(name), name, default) + +fun longPref(name: String, default: Long): Pref = + scalarPref(longPreferencesKey(name), name, default) + +fun floatPref(name: String, default: Float): Pref = + scalarPref(floatPreferencesKey(name), name, default) + +fun stringPref(name: String, default: String): Pref = + scalarPref(stringPreferencesKey(name), name, default) + +fun stringSetPref(name: String, default: Set = emptySet()): Pref> = + scalarPref(stringSetPreferencesKey(name), name, default) + +/** Absent reads back as `null`; writing `null` removes the key. */ +fun nullableStringPref(name: String): Pref = + nullablePref(stringPreferencesKey(name), name) + +/** Absent reads back as `null`; writing `null` removes the key. */ +fun nullableLongPref(name: String): Pref = + nullablePref(longPreferencesKey(name), name) + +/** An absent key reads back as [default]; the value is stored under [key] verbatim. */ +private fun scalarPref(key: Preferences.Key, name: String, default: T): Pref = + Pref( + name = name, + default = default, + key = key, + read = { it[key] ?: default }, + write = { preferences, value -> preferences[key] = value }, + ) + +/** + * Absence *is* the value here, so there is no default to fall back to and + * writing `null` removes the key rather than storing a sentinel for it. + */ +private fun nullablePref(key: Preferences.Key, name: String): Pref = + Pref( + name = name, + default = null, + key = key, + read = { it[key] }, + write = { preferences, value -> + if (value == null) preferences.remove(key) else preferences[key] = value + }, + ) diff --git a/core-prefs/src/main/kotlin/de/jeanlucmakiola/floret/prefs/PrefStore.kt b/core-prefs/src/main/kotlin/de/jeanlucmakiola/floret/prefs/PrefStore.kt new file mode 100644 index 0000000..8aa06eb --- /dev/null +++ b/core-prefs/src/main/kotlin/de/jeanlucmakiola/floret/prefs/PrefStore.kt @@ -0,0 +1,48 @@ +package de.jeanlucmakiola.floret.prefs + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.MutablePreferences +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +/** + * The typed view over a DataStore. The store's *name* and its construction stay + * with the app; this owns only the typed read/write vocabulary. + */ +class PrefStore(private val dataStore: DataStore) { + + /** The raw snapshot stream — the escape hatch for composing several keys in one map. */ + val data: Flow get() = dataStore.data + + /** [pref]'s value, distinct-until-changed, so an unrelated key's write doesn't re-emit. */ + fun flow(pref: Pref): Flow = dataStore.data.map(pref.read).distinctUntilChanged() + + suspend fun get(pref: Pref): T = pref.read(dataStore.data.first()) + + suspend fun set(pref: Pref, value: T) { + dataStore.edit { pref.write(it, value) } + } + + /** Atomic read-modify-write; returns the stored value after the edit. */ + suspend fun update(pref: Pref, transform: (T) -> T): T = + pref.read(dataStore.edit { pref.write(it, transform(pref.read(it))) }) + + /** Removes [pref]'s key, so it reads back as its default. */ + suspend fun clear(pref: Pref) { + dataStore.edit { it.remove(pref.key) } + } + + /** Removes every key in the store. */ + suspend fun clearAll() { + dataStore.edit { it.clear() } + } + + /** Raw multi-key edit, for the cases the typed API can't express. */ + suspend fun edit(transform: suspend (MutablePreferences) -> Unit) { + dataStore.edit { transform(it) } + } +} diff --git a/core-prefs/src/test/kotlin/de/jeanlucmakiola/floret/prefs/AppearancePrefsTest.kt b/core-prefs/src/test/kotlin/de/jeanlucmakiola/floret/prefs/AppearancePrefsTest.kt new file mode 100644 index 0000000..4b55eba --- /dev/null +++ b/core-prefs/src/test/kotlin/de/jeanlucmakiola/floret/prefs/AppearancePrefsTest.kt @@ -0,0 +1,96 @@ +package de.jeanlucmakiola.floret.prefs + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path + +/** + * The family's shared appearance preferences. The key names are the on-disk + * contract with Calendula and Agendula, so they are asserted literally. + */ +class AppearancePrefsTest { + + private fun TestScope.newStore(tempDir: Path): PrefStore = PrefStore( + PreferenceDataStoreFactory.create( + scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler) + Job()), + produceFile = { tempDir.resolve("test.preferences_pb").toFile() }, + ), + ) + + @Test + fun `the theme mode pref is stored under theme_mode and follows the system by default`() { + val pref = AppearancePrefs.themeMode + + assertThat(pref.name to pref.default).isEqualTo("theme_mode" to ThemeMode.SYSTEM) + } + + @Test + fun `the dynamic colour pref is stored under dynamic_color and defaults to on`() { + val pref = AppearancePrefs.dynamicColor + + assertThat(pref.name to pref.default).isEqualTo("dynamic_color" to true) + } + + @Test + fun `a fresh store reports the family's default appearance`(@TempDir tempDir: Path) = runTest { + val store = newStore(tempDir) + + val appearance = store.appearance().first() + + assertThat(appearance).isEqualTo(Appearance(ThemeMode.SYSTEM, dynamicColor = true)) + } + + @Test + fun `a stored theme mode shows up in the appearance`(@TempDir tempDir: Path) = runTest { + val store = newStore(tempDir) + + store.set(AppearancePrefs.themeMode, ThemeMode.DARK) + + assertThat(store.appearance().first()) + .isEqualTo(Appearance(ThemeMode.DARK, dynamicColor = true)) + } + + @Test + fun `a stored dynamic colour choice shows up in the appearance`(@TempDir tempDir: Path) = runTest { + val store = newStore(tempDir) + + store.set(AppearancePrefs.dynamicColor, false) + + assertThat(store.appearance().first()) + .isEqualTo(Appearance(ThemeMode.SYSTEM, dynamicColor = false)) + } + + @Test + fun `an appearance a sibling app wrote is read back`(@TempDir tempDir: Path) = runTest { + val store = newStore(tempDir) + + store.edit { it[stringPreferencesKey("theme_mode")] = "LIGHT" } + + assertThat(store.appearance().first().themeMode).isEqualTo(ThemeMode.LIGHT) + } + + @Test + fun `the appearance stream does not re-emit for an unrelated key`(@TempDir tempDir: Path) = runTest { + val store = newStore(tempDir) + + store.appearance().test { + awaitItem() + + store.edit { it[intPreferencesKey("unrelated")] = 1 } + + expectNoEvents() + cancel() + } + } +} diff --git a/core-prefs/src/test/kotlin/de/jeanlucmakiola/floret/prefs/EnumParsingTest.kt b/core-prefs/src/test/kotlin/de/jeanlucmakiola/floret/prefs/EnumParsingTest.kt new file mode 100644 index 0000000..ed8a7c8 --- /dev/null +++ b/core-prefs/src/test/kotlin/de/jeanlucmakiola/floret/prefs/EnumParsingTest.kt @@ -0,0 +1,65 @@ +package de.jeanlucmakiola.floret.prefs + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +/** + * `toEnum` is the read path for every enum-valued preference: whatever is on disk, + * it has to produce an entry rather than throw. + */ +class EnumParsingTest { + + @Test + fun `toEnum returns the entry whose name matches exactly`() { + val stored = "DARK" + + val parsed = stored.toEnum(ThemeMode.SYSTEM) + + assertThat(parsed).isEqualTo(ThemeMode.DARK) + } + + @Test + fun `toEnum falls back to the default when nothing is stored`() { + val stored: String? = null + + val parsed = stored.toEnum(ThemeMode.SYSTEM) + + assertThat(parsed).isEqualTo(ThemeMode.SYSTEM) + } + + @Test + fun `toEnum falls back to the default for an unknown value`() { + val stored = "FUNDAY" + + val parsed = stored.toEnum(ThemeMode.SYSTEM) + + assertThat(parsed).isEqualTo(ThemeMode.SYSTEM) + } + + @Test + fun `toEnum falls back to the default for the empty string`() { + val stored = "" + + val parsed = stored.toEnum(ThemeMode.SYSTEM) + + assertThat(parsed).isEqualTo(ThemeMode.SYSTEM) + } + + @Test + fun `toEnum matching is case sensitive`() { + val stored = "dark" + + val parsed = stored.toEnum(ThemeMode.SYSTEM) + + assertThat(parsed).isEqualTo(ThemeMode.SYSTEM) + } + + @Test + fun `toEnum round-trips every entry name`() { + val entries = ThemeMode.entries + + val parsed = entries.map { it.name.toEnum(ThemeMode.SYSTEM) } + + assertThat(parsed).containsExactlyElementsIn(entries).inOrder() + } +} diff --git a/core-prefs/src/test/kotlin/de/jeanlucmakiola/floret/prefs/PrefStoreTest.kt b/core-prefs/src/test/kotlin/de/jeanlucmakiola/floret/prefs/PrefStoreTest.kt new file mode 100644 index 0000000..ea34046 --- /dev/null +++ b/core-prefs/src/test/kotlin/de/jeanlucmakiola/floret/prefs/PrefStoreTest.kt @@ -0,0 +1,284 @@ +package de.jeanlucmakiola.floret.prefs + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path + +/** + * The typed read/write vocabulary over a real DataStore, one temp file per test. + * The store runs on the test scheduler so every write and the emission it causes + * are ordered against the assertions. + */ +class PrefStoreTest { + + private fun TestScope.newStore(tempDir: Path): PrefStore = PrefStore( + PreferenceDataStoreFactory.create( + scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler) + Job()), + produceFile = { tempDir.resolve("test.preferences_pb").toFile() }, + ), + ) + + @Test + fun `an unwritten boolean reads back as its default`(@TempDir tempDir: Path) = runTest { + val store = newStore(tempDir) + val pref = booleanPref("b", true) + + val value = store.get(pref) + + assertThat(value).isTrue() + } + + @Test + fun `a boolean round-trips`(@TempDir tempDir: Path) = runTest { + val store = newStore(tempDir) + val pref = booleanPref("b", true) + + store.set(pref, false) + + assertThat(store.get(pref)).isFalse() + } + + @Test + fun `an int reads its default then a written negative value`(@TempDir tempDir: Path) = runTest { + val store = newStore(tempDir) + val pref = intPref("i", 7) + assertThat(store.get(pref)).isEqualTo(7) + + store.set(pref, -3) + + assertThat(store.get(pref)).isEqualTo(-3) + } + + @Test + fun `a long round-trips its maximum value`(@TempDir tempDir: Path) = runTest { + val store = newStore(tempDir) + val pref = longPref("l", 0L) + + store.set(pref, Long.MAX_VALUE) + + assertThat(store.get(pref)).isEqualTo(Long.MAX_VALUE) + } + + @Test + fun `a float round-trips`(@TempDir tempDir: Path) = runTest { + val store = newStore(tempDir) + val pref = floatPref("f", 1f) + + store.set(pref, 63.5f) + + assertThat(store.get(pref)).isEqualTo(63.5f) + } + + @Test + fun `an empty string is a stored value, not an absent one`(@TempDir tempDir: Path) = runTest { + val store = newStore(tempDir) + val pref = stringPref("s", "fallback") + assertThat(store.get(pref)).isEqualTo("fallback") + + store.set(pref, "") + + assertThat(store.get(pref)).isEmpty() + } + + @Test + fun `a string set reads its default then exactly what was written`(@TempDir tempDir: Path) = runTest { + val store = newStore(tempDir) + val pref = stringSetPref("set", setOf("a")) + assertThat(store.get(pref)).containsExactly("a") + + store.set(pref, setOf("x", "y")) + + assertThat(store.get(pref)).containsExactly("x", "y") + } + + @Test + fun `an explicitly stored empty set is distinct from the default`(@TempDir tempDir: Path) = runTest { + val store = newStore(tempDir) + val pref = stringSetPref("set", setOf("a")) + + store.set(pref, emptySet()) + + assertThat(store.get(pref)).isEmpty() + } + + @Test + fun `an enum round-trips`(@TempDir tempDir: Path) = runTest { + val store = newStore(tempDir) + val pref = enumPref("mode", ThemeMode.SYSTEM) + + store.set(pref, ThemeMode.LIGHT) + + assertThat(store.get(pref)).isEqualTo(ThemeMode.LIGHT) + } + + @Test + fun `an unknown stored enum name degrades to the default`(@TempDir tempDir: Path) = runTest { + val store = newStore(tempDir) + val pref = enumPref("mode", ThemeMode.SYSTEM) + + store.edit { it[stringPreferencesKey("mode")] = "NOPE" } + + assertThat(store.get(pref)).isEqualTo(ThemeMode.SYSTEM) + } + + @Test + fun `an empty stored enum name degrades to the default`(@TempDir tempDir: Path) = runTest { + val store = newStore(tempDir) + val pref = enumPref("mode", ThemeMode.SYSTEM) + + store.edit { it[stringPreferencesKey("mode")] = "" } + + assertThat(store.get(pref)).isEqualTo(ThemeMode.SYSTEM) + } + + @Test + fun `a nullable string reads null when unset and the value once written`(@TempDir tempDir: Path) = runTest { + val store = newStore(tempDir) + val pref = nullableStringPref("opt") + assertThat(store.get(pref)).isNull() + + store.set(pref, "v") + + assertThat(store.get(pref)).isEqualTo("v") + } + + @Test + fun `writing null to a nullable string removes the key`(@TempDir tempDir: Path) = runTest { + val store = newStore(tempDir) + val pref = nullableStringPref("opt") + store.set(pref, "v") + + store.set(pref, null) + + assertThat(store.data.first().contains(stringPreferencesKey("opt"))).isFalse() + } + + @Test + fun `writing null to a nullable long removes the key`(@TempDir tempDir: Path) = runTest { + val store = newStore(tempDir) + val pref = nullableLongPref("id") + store.set(pref, 5L) + + store.set(pref, null) + + assertThat(store.data.first().contains(longPreferencesKey("id"))).isFalse() + } + + @Test + fun `clear restores a single pref to its default`(@TempDir tempDir: Path) = runTest { + val store = newStore(tempDir) + val pref = intPref("i", 7) + store.set(pref, 42) + + store.clear(pref) + + assertThat(store.get(pref)).isEqualTo(7) + } + + @Test + fun `clearAll restores every pref to its default`(@TempDir tempDir: Path) = runTest { + val store = newStore(tempDir) + val flag = booleanPref("b", true) + val count = intPref("i", 7) + val label = stringPref("s", "fallback") + store.set(flag, false) + store.set(count, 42) + store.set(label, "written") + + store.clearAll() + + assertThat(listOf(store.get(flag), store.get(count), store.get(label))) + .containsExactly(true, 7, "fallback").inOrder() + } + + @Test + fun `update transforms the stored value`(@TempDir tempDir: Path) = runTest { + val store = newStore(tempDir) + val pref = intPref("n", 0) + store.set(pref, 5) + + val returned = store.update(pref) { it + 1 } + + assertThat(listOf(returned, store.get(pref))).containsExactly(6, 6) + } + + @Test + fun `update of an unset pref transforms its default`(@TempDir tempDir: Path) = runTest { + val store = newStore(tempDir) + val pref = intPref("n", 10) + + val returned = store.update(pref) { it * 2 } + + assertThat(returned).isEqualTo(20) + } + + @Test + fun `a flow does not re-emit when an unrelated key is written`(@TempDir tempDir: Path) = runTest { + val store = newStore(tempDir) + val watched = booleanPref("watched", false) + val other = intPref("other", 0) + + store.flow(watched).test { + assertThat(awaitItem()).isFalse() + + store.set(other, 99) + + expectNoEvents() + cancel() + } + } + + @Test + fun `a flow does not re-emit when the same value is written again`(@TempDir tempDir: Path) = runTest { + val store = newStore(tempDir) + val watched = booleanPref("watched", false) + store.set(watched, true) + + store.flow(watched).test { + assertThat(awaitItem()).isTrue() + + store.set(watched, true) + + expectNoEvents() + cancel() + } + } + + @Test + fun `a flow emits exactly one item carrying a changed value`(@TempDir tempDir: Path) = runTest { + val store = newStore(tempDir) + val watched = booleanPref("watched", false) + + store.flow(watched).test { + awaitItem() + + store.set(watched, true) + + assertThat(awaitItem()).isTrue() + expectNoEvents() + cancel() + } + } + + @Test + fun `a typed pref and its raw key name are the same storage slot`(@TempDir tempDir: Path) = runTest { + val store = newStore(tempDir) + val pref = booleanPref("b", true) + + store.edit { it[booleanPreferencesKey("b")] = false } + + assertThat(store.get(pref)).isFalse() + } +} diff --git a/core-prefs/src/test/kotlin/de/jeanlucmakiola/floret/prefs/PrefTest.kt b/core-prefs/src/test/kotlin/de/jeanlucmakiola/floret/prefs/PrefTest.kt new file mode 100644 index 0000000..4ec96d3 --- /dev/null +++ b/core-prefs/src/test/kotlin/de/jeanlucmakiola/floret/prefs/PrefTest.kt @@ -0,0 +1,71 @@ +package de.jeanlucmakiola.floret.prefs + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +/** + * The `Pref` factories, as metadata only — a name and a default, with no store + * behind them. + */ +class PrefTest { + + @Test + fun `booleanPref reports its name and default`() { + val pref = booleanPref("flag", true) + + assertThat(pref.name to pref.default).isEqualTo("flag" to true) + } + + @Test + fun `the scalar factories each report their name and default`() { + val prefs = listOf( + intPref("i", 7), + longPref("l", 9L), + floatPref("f", 1.5f), + stringPref("s", "fallback"), + stringSetPref("set", setOf("a")), + ) + + val described = prefs.map { it.name to it.default } + + assertThat(described).containsExactly( + "i" to 7, + "l" to 9L, + "f" to 1.5f, + "s" to "fallback", + "set" to setOf("a"), + ).inOrder() + } + + @Test + fun `stringSetPref defaults to the empty set`() { + val pref = stringSetPref("s") + + assertThat(pref.default).isEmpty() + } + + @Test + fun `the nullable factories default to null`() { + val prefs = listOf(nullableStringPref("s"), nullableLongPref("l")) + + val defaults = prefs.map { it.default } + + assertThat(defaults).containsExactly(null, null) + } + + @Test + fun `enumPref reports its name and default entry`() { + val pref = enumPref("mode", ThemeMode.DARK) + + assertThat(pref.name to pref.default).isEqualTo("mode" to ThemeMode.DARK) + } + + @Test + fun `map keeps the name and derives the default through the decoder`() { + val pref = intPref("n", 60) + + val mapped = pref.map({ it.toString() }, { it.toInt() }) + + assertThat(mapped.name to mapped.default).isEqualTo("n" to "60") + } +} diff --git a/core-prefs/src/test/kotlin/de/jeanlucmakiola/floret/prefs/ThemeModeTest.kt b/core-prefs/src/test/kotlin/de/jeanlucmakiola/floret/prefs/ThemeModeTest.kt new file mode 100644 index 0000000..d42d47e --- /dev/null +++ b/core-prefs/src/test/kotlin/de/jeanlucmakiola/floret/prefs/ThemeModeTest.kt @@ -0,0 +1,51 @@ +package de.jeanlucmakiola.floret.prefs + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +/** The stored theme choice resolved against the device's light/dark setting. */ +class ThemeModeTest { + + @Test + fun `isDark follows a dark system when the mode is SYSTEM`() { + val mode = ThemeMode.SYSTEM + + val dark = mode.isDark(systemDark = true) + + assertThat(dark).isTrue() + } + + @Test + fun `isDark follows a light system when the mode is SYSTEM`() { + val mode = ThemeMode.SYSTEM + + val dark = mode.isDark(systemDark = false) + + assertThat(dark).isFalse() + } + + @Test + fun `isDark lets LIGHT override a dark system`() { + val mode = ThemeMode.LIGHT + + val dark = mode.isDark(systemDark = true) + + assertThat(dark).isFalse() + } + + @Test + fun `isDark lets DARK override a light system`() { + val mode = ThemeMode.DARK + + val dark = mode.isDark(systemDark = false) + + assertThat(dark).isTrue() + } + + @Test + fun `Appearance defaults to following the system with dynamic colour on`() { + val appearance = Appearance() + + assertThat(appearance).isEqualTo(Appearance(ThemeMode.SYSTEM, dynamicColor = true)) + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index db85bef..dfb1413 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -74,6 +74,8 @@ should scan the submodule's Gradle scripts (see §5). |---|---|---| | Plumbing | `core-time` (JVM) | Pure logic; no Android, no deps. | | Plumbing | `core-crash` (Android) | Self-contained subsystem; parameterized per app. | +| Plumbing | `core-prefs` (Android) | Typed DataStore layer. The store's name and construction stay with the app. | +| Plumbing | `core-di` (JVM) | Dispatcher/scope qualifiers + their Hilt module. The only module that knows about DI. | | Design — identity | `identity` (Android) | Theme factory + motion. App supplies seed/palette. | | Design — components | `components` (Android) | Shared Compose primitives. App composes screens. | @@ -93,7 +95,7 @@ Two kinds of module: - **JVM library** (`core-time`) — `org.jetbrains.kotlin.jvm`, `jvmTarget = 17`, JUnit 5 + Truth tests. The right home for pure logic; cheapest to build and test. -- **Android library** (`core-crash`, `identity`, `components`) — +- **Android library** (`core-crash`, `core-prefs`, `identity`, `components`) — `com.android.library` + the Compose plugin, `compileSdk 37` / `minSdk 29`. > **AGP 9.x provides built-in Kotlin compilation.** Android modules therefore @@ -101,6 +103,18 @@ Two kinds of module: > **not** a separate `org.jetbrains.kotlin.android` plugin (applying it errors). > They set `jvmTarget` directly in a `kotlin { compilerOptions { … } }` block. +> **An Android module with no Compose in it applies only `com.android.library`** +> — `core-prefs` is the first such module. The Compose plugin is added per +> module, when the module actually has composables. + +> **`core-di` is the one module carrying an annotation processor** — the KSP +> plugin plus `hilt-compiler`. A `@Module @InstallIn(...)` only reaches a +> consuming app's Hilt component if the *library* is processed: the app's +> processor reads `@AggregatedDeps` metadata off the classpath, it does not +> reprocess library sources. As a non-Android module it uses `hilt-core` + +> `hilt-compiler` rather than the Android Hilt Gradle plugin. Every other module +> stays DI-free. + Versions live in [`gradle/libs.versions.toml`](../gradle/libs.versions.toml), kept in lockstep with the apps' catalogs. diff --git a/docs/README.md b/docs/README.md index 952ea18..9c5964f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -3,7 +3,8 @@ floret-kit is the shared **Material 3 Expressive design system + plumbing** for the Floret family of Android apps ([Calendula](https://gitea.jeanlucmakiola.de/makiolaj/calendula), -[Agendula](https://gitea.jeanlucmakiola.de/makiolaj/agendula), and future +[Agendula](https://gitea.jeanlucmakiola.de/makiolaj/agendula), +[Clockula](https://gitea.jeanlucmakiola.de/makiolaj/clockula), and future siblings). It is consumed from source as a git submodule + Gradle composite build. See the top-level [`../README.md`](../README.md) for the pitch. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 34f6159..0f57ac4 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -15,20 +15,28 @@ divergent or app-specific code where it lives. | `core-reminders` | JVM | Agendula (and Calendula's pending branch) | | `core-locale` | Android | Agendula, Calendula | | `core-crash` | Android | Agendula | +| `core-prefs` | Android | Clockula | +| `core-di` | JVM | Clockula | | `identity` | Android | Agendula | | `components` | Android | Agendula | +| `glance` | Android | Agendula (Calendula pending — its own M3 Expressive widget pass) | See [`../CHANGELOG.md`](../CHANGELOG.md) for the contents of each. +### Follow-ups on what's extracted + +- **Migrate Calendula and Agendula onto `core-prefs` + `core-di`** — Clockula + triggered the extraction (the third app that made the duplication hurt) and is + its only consumer today. Both siblings still carry their own `ThemeMode`, + `toEnum()` and `@IoDispatcher`. The stored key names are identical + (`theme_mode`, `dynamic_color`), so each migration is a code change with **no + data migration** — deliberately out of scope for the extraction, and done by + each sibling when it next re-pins the kit. + --- ## Deferred / not yet worth it -- **`core-prefs`** (DataStore base: `ThemeMode`, `dynamicColor`, the typed - wrapper, `toEnum()`) and **`core-di`** (`@IoDispatcher` + provider) — genuinely - shared but **low value**: pure relocation, no new capability, the shared - surface is tiny once the store name and bindings stay app-local. Sensible to - defer until a third app makes the duplication hurt. - **`ReminderFormatting`** (`ReminderUnit` + `reminderLeadTimeLabel` + `decomposeReminder`) — the labels are `@Composable` over app-specific `R.plurals`, and the presets differ per app. Extract later behind a @@ -66,6 +74,8 @@ See [`../CHANGELOG.md`](../CHANGELOG.md) for the contents of each. ## Consumers & cadence - **Agendula** consumes the kit on `main` (active development). +- **Clockula** consumes the kit on `main` (active development); the first + consumer of `core-prefs` and `core-di`. - **Calendula** (ships via official F-Droid reproducible builds) has a pending adoption branch. Bringing it onto the kit is where the canonical-`GroupedSurface` convergence becomes visible, and where the F-Droid reproducibility checklist diff --git a/glance/build.gradle.kts b/glance/build.gradle.kts new file mode 100644 index 0000000..94d3771 --- /dev/null +++ b/glance/build.gradle.kts @@ -0,0 +1,63 @@ +// glance — the family's shared home-screen-widget plumbing and M3 Expressive +// widget vocabulary, built on Jetpack Glance: the theme factory, the rounded +// WidgetSurface (solid/tinted/transparent backgrounds with the system corner +// radius), a header + expressive action-button recipe, a position-aware +// grouped-row primitive for Glance (cornerRadius has no per-corner overload, so +// this comes from four shape drawables instead), and the midnight-rollover + +// update-receiver plumbing every widget needs. App-agnostic: each app supplies +// its own colour schemes, data and receivers. +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "de.jeanlucmakiola.floret.glance" + compileSdk = 37 + + defaultConfig { + minSdk = 29 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + buildFeatures { + compose = true + } + + testOptions { + unitTests { + all { it.useJUnitPlatform() } + isReturnDefaultValues = true + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +dependencies { + // components supplies Position/positionOf (the row-shape math) and pastelize. + implementation(project(":components")) + + implementation(libs.androidx.core.ktx) + implementation(libs.kotlinx.datetime) + + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.foundation) + implementation(libs.androidx.material3) + implementation(libs.androidx.glance.appwidget) + implementation(libs.androidx.glance.material3) + + testImplementation(libs.junit.jupiter.api) + testRuntimeOnly(libs.junit.jupiter.engine) + testRuntimeOnly(libs.junit.platform.launcher) + testImplementation(libs.truth) +} diff --git a/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/FloretGlanceTheme.kt b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/FloretGlanceTheme.kt new file mode 100644 index 0000000..9373922 --- /dev/null +++ b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/FloretGlanceTheme.kt @@ -0,0 +1,45 @@ +package de.jeanlucmakiola.floret.glance + +import android.os.Build +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.runtime.Composable +import androidx.glance.GlanceTheme +import androidx.glance.LocalContext +import androidx.glance.material3.ColorProviders + +/** + * A widget's own light/dark decision, independent of the app's night-resource + * qualifier: [SYSTEM] follows the device, [LIGHT]/[DARK] pin one regardless. + * An app's per-widget "Light" / "Dark" override maps straight to these; its + * "Follow app" option resolves to whichever of the three the app's own theme + * setting currently means, then passes that resolved value in. + */ +enum class WidgetThemeMode { SYSTEM, LIGHT, DARK } + +/** + * Glance counterpart of [FloretExpressiveTheme][de.jeanlucmakiola.floret.identity.FloretExpressiveTheme]: + * Material You on API 31+ when [dynamicColor] is on, the app-supplied + * [lightScheme] / [darkScheme] otherwise. On [WidgetThemeMode.SYSTEM] it draws + * only from colour-role tokens (`GlanceTheme.colors`), so light/dark follows the + * system without a redraw; [WidgetThemeMode.LIGHT] / [WidgetThemeMode.DARK] pin + * one scheme for both themes. + */ +@Composable +fun FloretGlanceTheme( + lightScheme: ColorScheme, + darkScheme: ColorScheme, + dynamicColor: Boolean, + mode: WidgetThemeMode, + content: @Composable () -> Unit, +) { + val dynamic = dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + val context = LocalContext.current + val colors = when (mode) { + WidgetThemeMode.SYSTEM -> if (dynamic) GlanceTheme.colors else ColorProviders(light = lightScheme, dark = darkScheme) + WidgetThemeMode.LIGHT -> ColorProviders(if (dynamic) dynamicLightColorScheme(context) else lightScheme) + WidgetThemeMode.DARK -> ColorProviders(if (dynamic) dynamicDarkColorScheme(context) else darkScheme) + } + GlanceTheme(colors = colors, content = content) +} diff --git a/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/FloretWidgetUpdateReceiver.kt b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/FloretWidgetUpdateReceiver.kt new file mode 100644 index 0000000..6789049 --- /dev/null +++ b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/FloretWidgetUpdateReceiver.kt @@ -0,0 +1,72 @@ +package de.jeanlucmakiola.floret.glance + +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch + +/** + * Redraws an app's widgets when their day or language goes stale, and keeps + * [WidgetRollover]'s midnight alarm armed. A subclass supplies the receiver(s) + * to check for placed instances, the rollover action + `PendingIntent`, how to + * redraw, and — for a widget whose data lives outside the app (a calendar + * provider, say) — any extra action that should trigger a redraw without + * re-arming the alarm, via [extraHandledActions]. + * + * Exported for the system broadcasts; an extra redraw from another app is + * harmless. + */ +abstract class FloretWidgetUpdateReceiver : BroadcastReceiver() { + + /** The action this app's own rollover alarm fires with. */ + protected abstract val rolloverAction: String + + /** Every widget receiver class whose placed instances this app's widgets span. */ + protected abstract val widgetReceiverClasses: List> + + /** Built once per call, so each firing targets the same alarm as the last. */ + protected abstract fun rolloverPendingIntent(context: Context): PendingIntent + + /** Redraws every widget this receiver is responsible for. */ + protected abstract suspend fun redraw(context: Context) + + /** Actions, beyond the shared set, that should redraw (never re-arm) — e.g. a content-provider change. */ + protected open val extraHandledActions: Set = emptySet() + + // `by lazy`, not eager: an eager initializer runs during *this* class's + // construction, before the subclass's `override val rolloverAction` + // initializer has run. `internal` (not `private`) so the wiring is directly + // testable without reflecting into a `Lazy` delegate field. + private val rearmOnlyActions = setOf(Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED) + internal val rearmActions by lazy { + rearmOnlyActions + setOf( + rolloverAction, + Intent.ACTION_TIME_CHANGED, + Intent.ACTION_TIMEZONE_CHANGED, + Intent.ACTION_DATE_CHANGED, + ) + } + internal val handledActions by lazy { rearmActions + Intent.ACTION_LOCALE_CHANGED + extraHandledActions } + + final override fun onReceive(context: Context, intent: Intent) { + if (intent.action !in handledActions) return + val appContext = context.applicationContext + val rearm = intent.action in rearmActions + val redraw = intent.action !in rearmOnlyActions + val pending = goAsync() + CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { + try { + if (rearm) WidgetRollover.sync(appContext, widgetReceiverClasses, ::rolloverPendingIntent) + if (redraw && WidgetRollover.hasPlacedWidgets(appContext, widgetReceiverClasses)) { + redraw(appContext) + } + } finally { + pending.finish() + } + } + } +} diff --git a/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetCheckButton.kt b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetCheckButton.kt new file mode 100644 index 0000000..39f223e --- /dev/null +++ b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetCheckButton.kt @@ -0,0 +1,51 @@ +package de.jeanlucmakiola.floret.glance + +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import androidx.glance.ColorFilter +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.action.Action +import androidx.glance.action.clickable +import androidx.glance.appwidget.cornerRadius +import androidx.glance.background +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.size +import androidx.glance.unit.ColorProvider + +/** + * A task row's leading "complete" tap target: a round hit area around the check + * glyph. [checked] fills it with `primary` — the frame a widget shows between + * the tap and the row going away, since RemoteViews can't animate the change. + */ +@Composable +fun WidgetCheckButton( + contentDescription: String, + action: Action, + tint: ColorProvider, + modifier: GlanceModifier = GlanceModifier, + checked: Boolean = false, +) { + val filled = if (checked) GlanceModifier.background(GlanceTheme.colors.primary) else GlanceModifier + Box( + modifier = modifier + .size(TargetSize) + .cornerRadius(TargetSize / 2) + .then(filled) + .clickable(action), + contentAlignment = Alignment.Center, + ) { + Image( + provider = ImageProvider(R.drawable.floret_ic_widget_check), + contentDescription = contentDescription, + colorFilter = ColorFilter.tint(if (checked) GlanceTheme.colors.onPrimary else tint), + modifier = GlanceModifier.size(IconSize), + ) + } +} + +private val TargetSize = 28.dp +private val IconSize = 16.dp diff --git a/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetDoneState.kt b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetDoneState.kt new file mode 100644 index 0000000..b0f215c --- /dev/null +++ b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetDoneState.kt @@ -0,0 +1,59 @@ +package de.jeanlucmakiola.floret.glance + +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.appwidget.cornerRadius +import androidx.glance.background +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.Column +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.padding +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextAlign +import androidx.glance.text.TextStyle + +/** + * A list widget's "nothing left" state, framed as an achievement rather than + * an absence: [title] and an optional [subtitle] in a tertiary card. Sits in + * its own container, like [WidgetMessage], so it stays readable on + * [WidgetBackground.TRANSPARENT]. + */ +@Composable +fun WidgetDoneState(title: String, subtitle: String? = null, modifier: GlanceModifier = GlanceModifier) { + Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column( + modifier = GlanceModifier + .cornerRadius(24.dp) + .background(GlanceTheme.colors.tertiaryContainer) + .padding(horizontal = 20.dp, vertical = 14.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = title, + maxLines = 1, + style = TextStyle( + color = GlanceTheme.colors.onTertiaryContainer, + fontSize = 17.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + ), + ) + subtitle?.let { + Text( + text = it, + maxLines = 2, + style = TextStyle( + color = GlanceTheme.colors.onTertiaryContainer, + fontSize = 13.sp, + textAlign = TextAlign.Center, + ), + ) + } + } + } +} diff --git a/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetGroupedRow.kt b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetGroupedRow.kt new file mode 100644 index 0000000..529112f --- /dev/null +++ b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetGroupedRow.kt @@ -0,0 +1,58 @@ +package de.jeanlucmakiola.floret.glance + +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import androidx.glance.ColorFilter +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.ImageProvider +import androidx.glance.background +import androidx.glance.layout.Box +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.padding +import androidx.glance.unit.ColorProvider +import de.jeanlucmakiola.floret.components.Position + +/** + * The Glance counterpart of `components`' `GroupedSurface`: a tonal container + * whose corners come from [position], so a run of rows reads as one rounded + * card. Glance's `cornerRadius` modifier takes one radius for the whole shape — + * no per-corner overload — so the asymmetric shapes are four tinted drawables + * (`floret_widget_row_*`) rather than a computed + * [androidx.compose.ui.graphics.Shape]. There is no press-morph here (Glance has + * no interaction source); the radii match + * [de.jeanlucmakiola.floret.components.groupedShape]'s resting values. + * + * [color] defaults to `surfaceVariant` — the nearest tonal-card role Glance's + * `ColorProviders` exposes (no `surfaceContainerHigh` there). On a [WidgetBackground.TRANSPARENT] + * root this container is what keeps the row's text readable over an arbitrary + * wallpaper (calendula#292): every row stays opaque regardless of the surface. + */ +@Composable +fun WidgetGroupedRow( + position: Position, + modifier: GlanceModifier = GlanceModifier, + color: ColorProvider = GlanceTheme.colors.surfaceVariant, + content: @Composable () -> Unit, +) { + val shape = when (position) { + Position.Alone -> R.drawable.floret_widget_row_alone + Position.Top -> R.drawable.floret_widget_row_top + Position.Middle -> R.drawable.floret_widget_row_middle + Position.Bottom -> R.drawable.floret_widget_row_bottom + } + val gap = if (position == Position.Top || position == Position.Middle) 2.dp else 0.dp + // The gap has to live on a Box with no background of its own: Android draws a + // View's background to its full bounds regardless of that View's own padding + // (padding only insets children), so putting both modifiers on one Box left + // the tinted shape covering the gap and rows touching. + Box(modifier = modifier.fillMaxWidth().padding(bottom = gap)) { + Box( + modifier = GlanceModifier + .fillMaxWidth() + .background(ImageProvider(shape), colorFilter = ColorFilter.tint(color)), + ) { + content() + } + } +} diff --git a/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetHeader.kt b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetHeader.kt new file mode 100644 index 0000000..98df204 --- /dev/null +++ b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetHeader.kt @@ -0,0 +1,184 @@ +package de.jeanlucmakiola.floret.glance + +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.ColorFilter +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.action.Action +import androidx.glance.action.clickable +import androidx.glance.appwidget.cornerRadius +import androidx.glance.background +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.Row +import androidx.glance.layout.RowScope +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.height +import androidx.glance.layout.padding +import androidx.glance.layout.size +import androidx.glance.layout.width +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextStyle +import androidx.glance.unit.ColorProvider + +/** Visual weight of a [WidgetActionButton]. */ +enum class WidgetActionStyle { TONAL, FILLED } + +/** + * A widget's title row: the title (clickable to [titleAction], when given), + * weighted to fill, and a trailing slot for [WidgetActionButton]s. On a + * [WidgetBackground.TRANSPARENT] root the title gets its own small pill behind + * it, so it stays legible over an arbitrary wallpaper (calendula#292); on a + * filled root it sits directly on the surface, as before. + */ +@Composable +fun WidgetHeader( + title: String, + modifier: GlanceModifier = GlanceModifier, + titleAction: Action? = null, + actions: @Composable RowScope.() -> Unit = {}, +) { + val transparentTitle = LocalWidgetBackground.current == WidgetBackground.TRANSPARENT + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + val titleModifier = GlanceModifier.defaultWeight().let { + if (titleAction != null) it.clickable(titleAction) else it + } + if (transparentTitle) { + Box( + modifier = titleModifier + .cornerRadius(12.dp) + .background(GlanceTheme.colors.widgetBackground) + .padding(horizontal = 10.dp, vertical = 4.dp), + ) { + HeaderText(title) + } + } else { + Box(modifier = titleModifier) { + HeaderText(title) + } + } + actions() + } +} + +@Composable +private fun HeaderText(title: String) { + Text( + text = title, + maxLines = 1, + style = TextStyle( + color = GlanceTheme.colors.primary, + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + ), + ) +} + +/** + * The standard trailing action of a [WidgetHeader]: a filled, flat pill + * ([HeaderActionWidth] × [HeaderActionHeight]) that leads without crowding + * the rows below it. + */ +@Composable +fun WidgetHeaderAction( + icon: ImageProvider, + contentDescription: String, + action: Action, + modifier: GlanceModifier = GlanceModifier, +) { + WidgetActionButton( + icon = icon, + contentDescription = contentDescription, + action = action, + modifier = modifier, + style = WidgetActionStyle.FILLED, + size = HeaderActionWidth, + height = HeaderActionHeight, + ) +} + +private val HeaderActionWidth = 36.dp +private val HeaderActionHeight = 28.dp + +/** + * A widget's primary tappable action: a [size] × [height] target (a 48dp + * circle by default) in a full-pill shape, [WidgetActionStyle.TONAL] + * (`primaryContainer`) by default or [WidgetActionStyle.FILLED] (`primary`) + * for the one action a widget wants to lead with. With [label] it widens into + * an extended pill ("+ New task") for a widget wide enough to carry one; + * without it, it stays [size] wide. A header that only has room to spare + * above a short list can pass a shorter [height] than [size] — a flatter pill + * rather than a full circle — to stay out of the rows' way. + */ +@Composable +fun WidgetActionButton( + icon: ImageProvider, + contentDescription: String, + action: Action, + modifier: GlanceModifier = GlanceModifier, + style: WidgetActionStyle = WidgetActionStyle.TONAL, + label: String? = null, + size: Dp = 48.dp, + height: Dp = size, +) { + val container = when (style) { + WidgetActionStyle.TONAL -> GlanceTheme.colors.primaryContainer + WidgetActionStyle.FILLED -> GlanceTheme.colors.primary + } + val content = when (style) { + WidgetActionStyle.TONAL -> GlanceTheme.colors.onPrimaryContainer + WidgetActionStyle.FILLED -> GlanceTheme.colors.onPrimary + } + val iconSize = height * 0.65f + if (label == null) { + Box( + modifier = modifier + .width(size) + .height(height) + .cornerRadius(height / 2) + .background(container) + .clickable(action), + contentAlignment = Alignment.Center, + ) { + ActionIcon(icon, contentDescription, content, iconSize = iconSize) + } + } else { + Row( + modifier = modifier + .height(height) + .cornerRadius(height / 2) + .background(container) + .clickable(action) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ActionIcon(icon, contentDescription, content, iconSize = iconSize) + Spacer(GlanceModifier.width(8.dp)) + Text( + text = label, + maxLines = 1, + style = TextStyle(color = content, fontSize = 14.sp, fontWeight = FontWeight.Medium), + ) + } + } +} + +@Composable +private fun ActionIcon(icon: ImageProvider, contentDescription: String, tint: ColorProvider, iconSize: Dp) { + Image( + provider = icon, + contentDescription = contentDescription, + colorFilter = ColorFilter.tint(tint), + modifier = GlanceModifier.size(iconSize), + ) +} diff --git a/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetMessage.kt b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetMessage.kt new file mode 100644 index 0000000..04cc7e5 --- /dev/null +++ b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetMessage.kt @@ -0,0 +1,43 @@ +package de.jeanlucmakiola.floret.glance + +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.appwidget.cornerRadius +import androidx.glance.background +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.padding +import androidx.glance.text.Text +import androidx.glance.text.TextAlign +import androidx.glance.text.TextStyle + +/** + * A widget's empty or unavailable state: centred text in its own tonal + * container, so it reads on [WidgetBackground.TRANSPARENT] exactly as the + * populated rows do. + */ +@Composable +fun WidgetMessage(text: String, modifier: GlanceModifier = GlanceModifier) { + Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Box( + modifier = GlanceModifier + .cornerRadius(16.dp) + .background(GlanceTheme.colors.surfaceVariant) + .padding(16.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = text, + style = TextStyle( + color = GlanceTheme.colors.onSurfaceVariant, + fontSize = 14.sp, + textAlign = TextAlign.Center, + ), + ) + } + } +} diff --git a/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetProgressRing.kt b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetProgressRing.kt new file mode 100644 index 0000000..7b5749d --- /dev/null +++ b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetProgressRing.kt @@ -0,0 +1,79 @@ +package de.jeanlucmakiola.floret.glance + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.RectF +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.unit.Dp +import androidx.glance.GlanceModifier +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.LocalContext +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.size +import androidx.glance.unit.ColorProvider +import kotlin.math.min +import kotlin.math.roundToInt + +/** + * A determinate ring, since Glance 1.2's own `CircularProgressIndicator` is + * indeterminate only — drawn as a bitmap (round-capped arc over its track) + * rather than a Compose `Shape`, the only way to get a determinate ring into + * a `RemoteViews` tree. [progress] is clamped to `0f..1f`; [content] (a + * count or label) is centred over it. + */ +@Composable +fun WidgetProgressRing( + progress: Float, + size: Dp, + trackColor: ColorProvider, + indicatorColor: ColorProvider, + modifier: GlanceModifier = GlanceModifier, + strokeWidth: Dp = size * STROKE_FRACTION, + content: @Composable () -> Unit = {}, +) { + val context = LocalContext.current + val density = context.resources.displayMetrics.density + val pixels = min((size.value * density).roundToInt(), MAX_BITMAP_PX) + val strokePixels = strokeWidth.value * density + val track = trackColor.getColor(context).toArgb() + val indicator = indicatorColor.getColor(context).toArgb() + val bitmap = ringBitmap(pixels, strokePixels, progress.coerceIn(0f, 1f), track, indicator) + Box(modifier = modifier.size(size), contentAlignment = Alignment.Center) { + Image( + provider = ImageProvider(bitmap), + contentDescription = null, + modifier = GlanceModifier.size(size), + ) + content() + } +} + +private fun ringBitmap(pixels: Int, strokePx: Float, progress: Float, trackArgb: Int, indicatorArgb: Int): Bitmap { + val bitmap = Bitmap.createBitmap(pixels, pixels, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + val inset = strokePx / 2 + val bounds = RectF(inset, inset, pixels - inset, pixels - inset) + val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = strokePx + strokeCap = Paint.Cap.ROUND + } + paint.color = trackArgb + canvas.drawOval(bounds, paint) + if (progress > 0f) { + paint.color = indicatorArgb + canvas.drawArc(bounds, START_ANGLE, SWEEP_DEGREES * progress, false, paint) + } + return bitmap +} + +private const val STROKE_FRACTION = 0.1f +private const val START_ANGLE = -90f +private const val SWEEP_DEGREES = 360f + +/** Keeps the bitmap well under the RemoteViews binder transaction limit. */ +private const val MAX_BITMAP_PX = 512 diff --git a/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetRollover.kt b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetRollover.kt new file mode 100644 index 0000000..d05935d --- /dev/null +++ b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetRollover.kt @@ -0,0 +1,79 @@ +package de.jeanlucmakiola.floret.glance + +import android.app.AlarmManager +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Context +import androidx.core.content.getSystemService +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.plus +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Clock +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant + +/** + * A widget family's own wake-up for the next local midnight, so a "Today" / + * date-relative label flips over on the day boundary. `ACTION_DATE_CHANGED` + * isn't delivered to manifest receivers since Android 8, which is why this + * exists. One inexact alarm at a time, re-armed by every firing: + * `setAndAllowWhileIdle` needs no permission and survives doze, and a rollover + * a few minutes late is invisible on a sleeping screen. + * + * App-agnostic: [widgetReceiverClasses] names the receiver(s) whose placed + * instances decide whether the alarm is worth keeping armed, and + * [rolloverPendingIntent] builds the `PendingIntent` the app's own + * `FloretWidgetUpdateReceiver` subclass will receive it on. + */ +object WidgetRollover { + + /** Fire just after midnight, so an early delivery doesn't still read the old date. */ + val ROLLOVER_SLACK = 5.seconds + + private const val MAX_LOOKAHEAD_DAYS = 3 + + /** Arm the next rollover, or cancel it when no widget of [widgetReceiverClasses] is placed. Idempotent. */ + fun sync( + context: Context, + widgetReceiverClasses: List>, + rolloverPendingIntent: (Context) -> PendingIntent, + ) { + val appContext = context.applicationContext + val alarmManager = appContext.getSystemService() ?: return + val pendingIntent = rolloverPendingIntent(appContext) + if (!hasPlacedWidgets(appContext, widgetReceiverClasses)) { + alarmManager.cancel(pendingIntent) + return + } + val triggerAt = nextRolloverAt(Clock.System.now(), TimeZone.currentSystemDefault()) + alarmManager.setAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, triggerAt.toEpochMilliseconds(), pendingIntent, + ) + } + + /** + * The instant just after the next local midnight following [now] in [zone]. + * Uses the actual start of day, so it holds where DST skips midnight or a + * date-line move skips a whole date. + */ + fun nextRolloverAt(now: Instant, zone: TimeZone): Instant { + val date = now.toLocalDateTime(zone).date + var days = 1 + while (days <= MAX_LOOKAHEAD_DAYS) { + val candidate = date.plus(days, DateTimeUnit.DAY).atStartOfDayIn(zone) + ROLLOVER_SLACK + if (candidate > now) return candidate + days++ + } + // Unreachable for any real zone; an hour rather than the slack so it can't loop. + return now + 1.hours + } + + fun hasPlacedWidgets(context: Context, widgetReceiverClasses: List>): Boolean { + val manager = AppWidgetManager.getInstance(context) ?: return false + return widgetReceiverClasses.any { manager.getAppWidgetIds(ComponentName(context, it)).isNotEmpty() } + } +} diff --git a/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetShape.kt b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetShape.kt new file mode 100644 index 0000000..a42db0f --- /dev/null +++ b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetShape.kt @@ -0,0 +1,67 @@ +package de.jeanlucmakiola.floret.glance + +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.Dp +import androidx.glance.ColorFilter +import androidx.glance.GlanceModifier +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.action.Action +import androidx.glance.action.clickable +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.size +import androidx.glance.unit.ColorProvider + +/** + * M3 Expressive shapes for widgets. RemoteViews can't clip to an arbitrary + * outline, so each is a tinted vector drawn behind the content instead. + */ +enum class WidgetShape(internal val drawable: Int) { + COOKIE(R.drawable.floret_widget_shape_cookie), + SUNNY(R.drawable.floret_widget_shape_sunny), +} + +/** A [size] square filled with [shape] in [color], with [content] centred on top. */ +@Composable +fun WidgetShapedBox( + shape: WidgetShape, + color: ColorProvider, + size: Dp, + modifier: GlanceModifier = GlanceModifier, + onClick: Action? = null, + content: @Composable () -> Unit = {}, +) { + val clickable = if (onClick != null) GlanceModifier.clickable(onClick) else GlanceModifier + Box(modifier = modifier.size(size).then(clickable), contentAlignment = Alignment.Center) { + Image( + provider = ImageProvider(shape.drawable), + contentDescription = null, + colorFilter = ColorFilter.tint(color), + modifier = GlanceModifier.size(size), + ) + content() + } +} + +/** A [shape]-backed icon button — a widget's hero action, louder than a pill. */ +@Composable +fun WidgetShapedIconButton( + shape: WidgetShape, + icon: ImageProvider, + contentDescription: String, + action: Action, + color: ColorProvider, + iconTint: ColorProvider, + size: Dp, + modifier: GlanceModifier = GlanceModifier, +) { + WidgetShapedBox(shape = shape, color = color, size = size, modifier = modifier, onClick = action) { + Image( + provider = icon, + contentDescription = contentDescription, + colorFilter = ColorFilter.tint(iconTint), + modifier = GlanceModifier.size(size * 0.42f), + ) + } +} diff --git a/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetSurface.kt b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetSurface.kt new file mode 100644 index 0000000..ac82d14 --- /dev/null +++ b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetSurface.kt @@ -0,0 +1,60 @@ +package de.jeanlucmakiola.floret.glance + +import android.os.Build +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.unit.dp +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.appwidget.appWidgetBackground +import androidx.glance.appwidget.cornerRadius +import androidx.glance.background +import androidx.glance.layout.Box +import androidx.glance.layout.fillMaxSize + +/** + * How a widget's root fills in behind its content. [TINTED] is the softer tone + * requested for the M3 Expressive pass (calendula#90); [TRANSPARENT] draws no + * fill at all, so rows must stay in their own opaque [WidgetGroupedRow] + * container to keep contrast on an arbitrary wallpaper (calendula#292). + */ +enum class WidgetBackground { SOLID, TINTED, TRANSPARENT } + +/** The [WidgetBackground] the nearest [WidgetSurface] was given, for a child that adapts to it. */ +val LocalWidgetBackground = compositionLocalOf { WidgetBackground.SOLID } + +/** Falls back to this radius below API 31, where there's no system widget-corner dimension. */ +private val LegacyWidgetCornerRadius = 20.dp + +/** + * A widget's root container: fills the host, takes the system's rounded corner + * on API 31+ (a fixed fallback below that), and fills with [background]. + * `appWidgetBackground()` lets the launcher draw its own selection/press + * affordance behind the widget; it carries no colour of its own, so the fill is + * layered on top of it here. + */ +@Composable +fun WidgetSurface( + background: WidgetBackground, + modifier: GlanceModifier = GlanceModifier, + content: @Composable () -> Unit, +) { + val radius = modifier.appWidgetBackground().let { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + it.cornerRadius(android.R.dimen.system_app_widget_background_radius) + } else { + it.cornerRadius(LegacyWidgetCornerRadius) + } + } + val filled = when (background) { + WidgetBackground.SOLID -> radius.background(GlanceTheme.colors.widgetBackground) + WidgetBackground.TINTED -> radius.background(GlanceTheme.colors.secondaryContainer) + WidgetBackground.TRANSPARENT -> radius + } + CompositionLocalProvider(LocalWidgetBackground provides background) { + Box(modifier = filled.fillMaxSize()) { + content() + } + } +} diff --git a/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetTile.kt b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetTile.kt new file mode 100644 index 0000000..0dc9596 --- /dev/null +++ b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetTile.kt @@ -0,0 +1,42 @@ +package de.jeanlucmakiola.floret.glance + +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import androidx.glance.GlanceModifier +import androidx.glance.action.Action +import androidx.glance.action.clickable +import androidx.glance.appwidget.cornerRadius +import androidx.glance.background +import androidx.glance.layout.Box +import androidx.glance.layout.fillMaxHeight +import androidx.glance.layout.padding +import androidx.glance.unit.ColorProvider + +/** + * A fully-rounded tonal block — the Lists widget's per-list tile and the Week + * widget's per-day column share this shape. Unlike [WidgetGroupedRow] it is + * always [de.jeanlucmakiola.floret.components.Position.Alone]-shaped (every + * tile stands on its own, with its own gap to its neighbours), so it uses a + * plain `cornerRadius` rather than the position-aware shape drawables. + */ +@Composable +fun WidgetTile( + color: ColorProvider, + modifier: GlanceModifier = GlanceModifier, + onClick: Action? = null, + content: @Composable () -> Unit, +) { + val clickable = if (onClick != null) GlanceModifier.clickable(onClick) else GlanceModifier + Box( + modifier = modifier + .fillMaxHeight() + .cornerRadius(TileRadius) + .background(color) + .then(clickable) + .padding(TilePadding), + content = content, + ) +} + +private val TileRadius = 16.dp +private val TilePadding = 8.dp diff --git a/glance/src/main/res/drawable/floret_ic_widget_add.xml b/glance/src/main/res/drawable/floret_ic_widget_add.xml new file mode 100644 index 0000000..4797a8b --- /dev/null +++ b/glance/src/main/res/drawable/floret_ic_widget_add.xml @@ -0,0 +1,6 @@ + + + diff --git a/glance/src/main/res/drawable/floret_ic_widget_check.xml b/glance/src/main/res/drawable/floret_ic_widget_check.xml new file mode 100644 index 0000000..509779c --- /dev/null +++ b/glance/src/main/res/drawable/floret_ic_widget_check.xml @@ -0,0 +1,10 @@ + + + + diff --git a/glance/src/main/res/drawable/floret_ic_widget_checklist.xml b/glance/src/main/res/drawable/floret_ic_widget_checklist.xml new file mode 100644 index 0000000..b99d82b --- /dev/null +++ b/glance/src/main/res/drawable/floret_ic_widget_checklist.xml @@ -0,0 +1,11 @@ + + + + diff --git a/glance/src/main/res/drawable/floret_widget_row_alone.xml b/glance/src/main/res/drawable/floret_widget_row_alone.xml new file mode 100644 index 0000000..f37f2c6 --- /dev/null +++ b/glance/src/main/res/drawable/floret_widget_row_alone.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/glance/src/main/res/drawable/floret_widget_row_bottom.xml b/glance/src/main/res/drawable/floret_widget_row_bottom.xml new file mode 100644 index 0000000..279fc2b --- /dev/null +++ b/glance/src/main/res/drawable/floret_widget_row_bottom.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/glance/src/main/res/drawable/floret_widget_row_middle.xml b/glance/src/main/res/drawable/floret_widget_row_middle.xml new file mode 100644 index 0000000..431302c --- /dev/null +++ b/glance/src/main/res/drawable/floret_widget_row_middle.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/glance/src/main/res/drawable/floret_widget_row_top.xml b/glance/src/main/res/drawable/floret_widget_row_top.xml new file mode 100644 index 0000000..e9687f8 --- /dev/null +++ b/glance/src/main/res/drawable/floret_widget_row_top.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/glance/src/main/res/drawable/floret_widget_shape_cookie.xml b/glance/src/main/res/drawable/floret_widget_shape_cookie.xml new file mode 100644 index 0000000..d297723 --- /dev/null +++ b/glance/src/main/res/drawable/floret_widget_shape_cookie.xml @@ -0,0 +1,7 @@ + + + + diff --git a/glance/src/main/res/drawable/floret_widget_shape_sunny.xml b/glance/src/main/res/drawable/floret_widget_shape_sunny.xml new file mode 100644 index 0000000..3388604 --- /dev/null +++ b/glance/src/main/res/drawable/floret_widget_shape_sunny.xml @@ -0,0 +1,7 @@ + + + + diff --git a/glance/src/test/kotlin/de/jeanlucmakiola/floret/glance/FloretWidgetUpdateReceiverTest.kt b/glance/src/test/kotlin/de/jeanlucmakiola/floret/glance/FloretWidgetUpdateReceiverTest.kt new file mode 100644 index 0000000..34874c3 --- /dev/null +++ b/glance/src/test/kotlin/de/jeanlucmakiola/floret/glance/FloretWidgetUpdateReceiverTest.kt @@ -0,0 +1,51 @@ +package de.jeanlucmakiola.floret.glance + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +/** Exercises the action-set wiring via reflection, since it's computed from `protected` members. */ +class FloretWidgetUpdateReceiverTest { + + private class Fake(override val extraHandledActions: Set = emptySet()) : FloretWidgetUpdateReceiver() { + override val rolloverAction = "app.widget.ROLLOVER" + override val widgetReceiverClasses: List> = emptyList() + override fun rolloverPendingIntent(context: Context): PendingIntent = error("not exercised") + override suspend fun redraw(context: Context) = Unit + } + + @Test + fun `the rollover action is handled and re-arms`() { + val receiver = Fake() + assertThat(receiver.handledActions).contains("app.widget.ROLLOVER") + assertThat(receiver.rearmActions).contains("app.widget.ROLLOVER") + } + + @Test + fun `a language change redraws without re-arming`() { + val receiver = Fake() + assertThat(receiver.handledActions).contains(Intent.ACTION_LOCALE_CHANGED) + assertThat(receiver.rearmActions).doesNotContain(Intent.ACTION_LOCALE_CHANGED) + } + + @Test + fun `everything that loses the alarm re-arms it`() { + assertThat(Fake().rearmActions).containsAtLeast( + "app.widget.ROLLOVER", + Intent.ACTION_BOOT_COMPLETED, + Intent.ACTION_MY_PACKAGE_REPLACED, + Intent.ACTION_TIME_CHANGED, + Intent.ACTION_TIMEZONE_CHANGED, + Intent.ACTION_DATE_CHANGED, + ) + } + + @Test + fun `an extra action is handled but never re-arms`() { + val receiver = Fake(extraHandledActions = setOf("app.PROVIDER_CHANGED")) + assertThat(receiver.handledActions).contains("app.PROVIDER_CHANGED") + assertThat(receiver.rearmActions).doesNotContain("app.PROVIDER_CHANGED") + } +} diff --git a/glance/src/test/kotlin/de/jeanlucmakiola/floret/glance/WidgetRolloverTest.kt b/glance/src/test/kotlin/de/jeanlucmakiola/floret/glance/WidgetRolloverTest.kt new file mode 100644 index 0000000..fffe7a2 --- /dev/null +++ b/glance/src/test/kotlin/de/jeanlucmakiola/floret/glance/WidgetRolloverTest.kt @@ -0,0 +1,145 @@ +package de.jeanlucmakiola.floret.glance + +import com.google.common.truth.Truth.assertThat +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime +import org.junit.jupiter.api.Test +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Instant + +/** The "when is the next local midnight" arithmetic, especially where midnight is not 00:00. */ +class WidgetRolloverTest { + + private val berlin = TimeZone.of("Europe/Berlin") + + private fun at(local: String, zone: TimeZone): Instant = + LocalDateTime.parse(local).toInstant(zone) + + private fun nextRollover(local: String, zone: TimeZone = berlin): Instant = + WidgetRollover.nextRolloverAt(at(local, zone), zone) + + // --- the ordinary day ---------------------------------------------------- + + @Test + fun `midday rolls over at the coming midnight`() { + val next = nextRollover("2026-08-27T12:00:00") + assertThat(next).isEqualTo(at("2026-08-28T00:00:05", berlin)) + } + + @Test + fun `a second before midnight still targets tonight, not tomorrow night`() { + val next = nextRollover("2026-08-27T23:59:59") + assertThat(next).isEqualTo(at("2026-08-28T00:00:05", berlin)) + } + + @Test + fun `at midnight exactly the target is the next day, never the current instant`() { + // Re-arming after a firing must move a whole day on, or the widget wakes + // itself in a tight loop. + val now = at("2026-08-28T00:00:00", berlin) + val next = WidgetRollover.nextRolloverAt(now, berlin) + assertThat(next).isEqualTo(at("2026-08-29T00:00:05", berlin)) + assertThat(next - now).isGreaterThan(Duration.ZERO) + } + + @Test + fun `re-arming from the slack instant itself moves a full day on`() { + // What actually happens in practice: the receiver runs at midnight + slack. + val now = at("2026-08-28T00:00:05", berlin) + assertThat(WidgetRollover.nextRolloverAt(now, berlin)) + .isEqualTo(at("2026-08-29T00:00:05", berlin)) + } + + @Test + fun `the result is always in the future for every minute of a day`() { + val zone = berlin + // Spans Berlin's 2024 spring-forward: the likeliest source of a target + // in the past, i.e. an alarm that fires immediately, forever. + var probe = LocalDateTime.parse("2024-03-29T00:00:00").toInstant(zone) + val end = LocalDateTime.parse("2024-04-01T00:00:00").toInstant(zone) + while (probe < end) { + assertThat(WidgetRollover.nextRolloverAt(probe, zone)).isGreaterThan(probe) + probe += 1.minutes + } + } + + // --- daylight saving ----------------------------------------------------- + + @Test + fun `spring forward keeps the rollover one day away, not one hour short`() { + // Berlin skipped 02:00-03:00 on 31 March 2024, so that day was 23h long. + // A rollover computed as "now + 24h" would land at 01:00 on 1 April. + val now = at("2024-03-30T12:00:00", berlin) + val next = WidgetRollover.nextRolloverAt(now, berlin) + assertThat(next).isEqualTo(at("2024-03-31T00:00:05", berlin)) + assertThat(next - now).isLessThan(24.hours) + } + + @Test + fun `fall back does not overshoot into the repeated hour`() { + // Berlin repeated 02:00-03:00 on 27 October 2024: a 25h day, so + // "now + 24h" would land at 23:00 on the 26th and never roll over. + val now = at("2024-10-26T12:00:00", berlin) + val next = WidgetRollover.nextRolloverAt(now, berlin) + assertThat(next).isEqualTo(at("2024-10-27T00:00:05", berlin)) + assertThat(next.toLocalDateTime(berlin).date).isEqualTo(LocalDate.parse("2024-10-27")) + } + + @Test + fun `a zone that repeats midnight takes the first start of day`() { + // Sao Paulo used to end DST by moving 00:00 back to 23:00, so the day + // began twice. Pinned as the accepted trade: the widget runs an hour + // ahead until the next redraw. No live zone does this since 2019. + val saoPaulo = TimeZone.of("America/Sao_Paulo") + val next = WidgetRollover.nextRolloverAt(at("2018-02-16T12:00:00", saoPaulo), saoPaulo) + val local = next.toLocalDateTime(saoPaulo) + assertThat(local.date).isEqualTo(LocalDate.parse("2018-02-17")) + assertThat(local.hour).isEqualTo(0) + } + + @Test + fun `a zone where midnight does not exist rolls over at the real start of day`() { + // Cuba starts DST at 00:00, so 11 March 2018 began at 01:00 in Havana. + // Targeting a literal 00:00 there would arm an instant on the wrong day. + val havana = TimeZone.of("America/Havana") + val next = WidgetRollover.nextRolloverAt(at("2018-03-10T12:00:00", havana), havana) + val local = next.toLocalDateTime(havana) + assertThat(local.date).isEqualTo(LocalDate.parse("2018-03-11")) + assertThat(local.hour).isEqualTo(1) + assertThat(local.minute).isEqualTo(0) + // And it is genuinely the first instant of that date, not a guess. + assertThat(next).isEqualTo( + LocalDate.parse("2018-03-11").atStartOfDayIn(havana) + WidgetRollover.ROLLOVER_SLACK, + ) + } + + // --- timezone changes ---------------------------------------------------- + + @Test + fun `the same instant rolls over at different times in different zones`() { + // TIMEZONE_CHANGED must re-arm to the new local midnight: the arithmetic + // follows the zone, not a cached offset. + val instant = at("2026-08-27T12:00:00", berlin) + val tokyo = TimeZone.of("Asia/Tokyo") + val berlinNext = WidgetRollover.nextRolloverAt(instant, berlin) + val tokyoNext = WidgetRollover.nextRolloverAt(instant, tokyo) + assertThat(tokyoNext).isNotEqualTo(berlinNext) + assertThat(tokyoNext).isLessThan(berlinNext) + assertThat(tokyoNext.toLocalDateTime(tokyo).hour).isEqualTo(0) + } + + @Test + fun `a half-hour offset zone still lands on its own midnight`() { + val kathmandu = TimeZone.of("Asia/Kathmandu") + val next = WidgetRollover.nextRolloverAt(at("2026-08-27T12:00:00", kathmandu), kathmandu) + val local = next.toLocalDateTime(kathmandu) + assertThat(local.date.toString()).isEqualTo("2026-08-28") + assertThat(local.hour).isEqualTo(0) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 87ea3d6..f9f5d59 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,16 +1,24 @@ # Version catalog for floret-kit. Kept in lockstep with the consuming apps' # catalogs (same AGP/Kotlin/Compose + test stack) until the catalog itself is shared. [versions] -agp = "9.2.1" -kotlin = "2.3.21" -coreKtx = "1.19.0" -appcompat = "1.7.1" +agp = "9.4.1" +kotlin = "2.4.20" +coreKtx = "1.19.1" +appcompat = "1.8.0" activityCompose = "1.13.0" -composeBom = "2026.05.01" -# Material 3 Expressive APIs live in the 1.5 alpha line; pinned to override the BOM. -material3 = "1.5.0-alpha21" -junit = "6.1.0" -junitPlatform = "6.1.0" +composeBom = "2026.09.00" +# Material 3 Expressive APIs live in the 1.5 alpha line (no stable 1.5 yet); pinned to override the BOM. +material3 = "1.5.0-alpha29" +junit = "6.1.3" +junitPlatform = "6.1.3" +ksp = "2.3.9" +hilt = "2.59.2" +datastore = "1.2.1" +kotlinxCoroutines = "1.10.2" +turbine = "1.2.0" +# Glance: kept in lockstep with the apps' widget stack. +glance = "1.2.0" +kotlinxDatetime = "0.7.0" truth = "1.4.5" [libraries] @@ -23,6 +31,16 @@ androidx-foundation = { group = "androidx.compose.foundation", name = "foundatio 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-glance-appwidget = { group = "androidx.glance", name = "glance-appwidget", version.ref = "glance" } +androidx-glance-material3 = { group = "androidx.glance", name = "glance-material3", version.ref = "glance" } +kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version.ref = "kotlinxDatetime" } + +androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } +kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } +turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" } +hilt-core = { group = "com.google.dagger", name = "hilt-core", version.ref = "hilt" } +hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" } junit-jupiter-api = { group = "org.junit.jupiter", name = "junit-jupiter-api", version.ref = "junit" } junit-jupiter-engine = { group = "org.junit.jupiter", name = "junit-jupiter-engine", version.ref = "junit" } @@ -33,3 +51,4 @@ truth = { group = "com.google.truth", name = "truth", version.ref = "truth" } android-library = { id = "com.android.library", version.ref = "agp" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 1b33c55baabb587c669f562ae36f953de2481846..5097068a8d375f5bf0d15693fb5b3615c972a801 100644 GIT binary patch delta 39622 zcmXuKQ(&Fn_dMLjII&J_+fEwWW@FnvjZSQ{v6IG58ryanqfuk$pU?OAzBkX+bG7%( zTC--&zFmX}yM?H=MFzRYiXil{ph`RhZjiz{1+||sX(x#4K{d6EBf#v?$?g1t$}W*C92%5J-5V_2Lrkgo zZ5G$-O6ZXF=l4tEFHT=O0ES#y-nRp$q*_)OLOW~+Voa`TG>mj9>plDMi5JR+Gzk;M zG>~oLZFIJ3*D`c&5n_gBfoKwWnX&UAj+mr|^m?Y}E+yM)8SO&)@{|xO4x*g6Cs!08 zBq(87aW`jf#3;UlVIo_a1FTx%KPdv-GaUY%ZKH^*~ z3cH6Nx3hQZuTp`+O3*#`e+p=ybNre>+8On0fmdL-Q)yfdNa4}nlo<84zf|ObMBGN zwr~}`edrrFZY!Wa0VB`%Xml#k5+;KPE!$c}$w$Yn=QW0B< zS}JAKi;&11_{v>LQUI{ZCC+(OWQtB_U&=X(aZqb&C;Q3hFOxwTaemce|4Ab!Z?&T6SgR7Pi51OBy`-~ zSMF+{8vJdGgqJT#kO~C57F_qhd#-n&-eAg-Iy_HQ5_XjT;i_})Hv&%NTq0>4vJm6B zg{h|=v$MKsK|cSY?`$L5ip%GI#j9lOpz16O0)R5|XK>=w#7k5ML|Y0BB)HErW*TTp zJ#J@pOU@|K!)M>UTulBBetrIJ?DY4G*bDGIg#M-VS@^d{xHEWcpEMG1$;u`&3+P9g zsPP+e+CjW<2;h0XODN-Wid$mfi)D#LhR#924crC}LM&oX5X6DQC016x|A)srY36Vj zXoxRgvLTcH@zW>$rpHc-#2^IO{)b1wdF)iC;$pH#Y<-cJB0S1+Bl-9NB_v>F`t{(% zh2r?!=BkB96gqjt$1qBI9!2IO%&yrQEr>-(a|qQ;V%N>u{(P^ zJ5krZ{rj3~((BrsRqZM)4BNK6Oeloku?4rW_qF=41W+5>#)Au*=s{H$GxrdtO;elS zFrzgKkN;`To;Yn(E-Sor%V=u|#r$e9NF#U=g&vTNy}lm14#wK+hXMv0-mQ_Do6djQ!cC4l=_}WI;0H5oztrzMF|BvgJu3Q zhxC?ZlOFe-f6e3s{{s!jy7f^5A^FGw%45SPn=KLU60>T)WoF72;!KVd5RubMHpj|jvfo;gh(gY|HepcQd}n!U zi<+7fP_!+=*U12TETxaBt^DV(_<6@pj zWUH|CCsl^VH=M*J%e5BpepR;P z>{+$xGZ50FFA=>Qe0KoC;1VIO=a-oQ@$qo+h*WZ=dVu@9mBzNai4JgJmGK%gdM;*M zvPxVZkFkoyR-LiGC9S>oQY#TMD$RDH>#_XHG|h*UZmijWyPRs@^-S$Q9^-6BE?Ux{ zV~b;t%Jw9lvAq5MzI`}*+Q#lyZc5B zN#SPJf&SEl+F;{`gI@>oLP$X@x3GaA{2B+dUrwcblYiT2Jd8b2JWNo;zOOfi_ycmV zcGw7X6y2I1rLCBX%O^1hzYdt64fncx!+6I#g=;(a*J59!-;^{N!FYrPZpQ7h#ClC6 z0!l?OI{HANY_PaoHlQopkp=?=m7;_bQs{DkNi_yS3@TIJ!5sV)H$6FY#m`&NwmCtG} z1vUqFeC|AnUf}@EDkblUokpBpfS8mEO^|CP*=tS99htlr8Wk>C&7Its9_*R{obOj0 z!81zSexkou9v*MZ1#M$je4WgoYMP_FSb?55>Q((TXC!ZM4Q)Gp*@fVhe4!@hy}jYB zLx`cM?Tz}>JJ5fDoU*-lB~|E``P^@0md~#*SQ6fC4kf$<;RNMa!7{b>sXAs)!h*-EzRk`6=T`Y&v3G6)bu%K`U$3M@AxrL z1>_u&NgfbiEO*VFeuX4-1pLZ{whZ(pq_~&CV+X|%9>2o>$2|ln8V3W&FJES0|Hr*? zTv3QpYQYGrWYnI|jSa(+iqXB- zf6#vz+S5t+RA#;~yD;E7c>50J7#8&7sFk3x+tWhTM2+z+ej>+r`(@+Ef9qiC0R+0^ z{4xHIw!cd%c4H<$%1c@y#)X{l0ge!aOap0x18iC^^W?h-^9xpTjn9a z*{4?MA!HlI57yW`taD<*n67ARrD9n^K|v|z1|(E38m+b4f zb~+6?0a*dEmPpVeDL^@iDe?7YL+ z58-aBl547_i1mQ#8#Q`YMHYOuJAoG5RBGgMQyaal+1qBtaaC&c%yYP4#>{-)b>No0 zVcz;p2j6c?MAuStVlj8uaUtA0Z4h=y$N>P!wUu(GlSI>`ExH(PF(2k=yi6hoSe=MA zV8Yt2432gkkWQ$BI*L?Dub<`MtCklUM+!5RYADUD!OP&ct!fshEKs-^}>03hBViOtK}@s8G0?WNUI$p0hTu>%1?j0^Bb?3 z-B2otOjio&^ESU6>54bcpnpt_36Ep56#sQ*?qO<(BKTfWY-cr_JwG{FeMm!ZROc`~ z_0ZaUr4jS@)h-`lz87aDh-0z9Hg&sLx+9{QJ)CewqOZJ-ph*V&sE@93c3+;*ziiU- zqct|aqPz(^LI;lB`VIuw1(6?@zr-qMV58%DfL-53#0|IpPST9HG~RLOkwIszk(9{Q zRt+pEwDVq&404Lk?TEAs23@+izYwaKn$wLf_V04EsHGlqA3e9-;z>$k zVquB$aN@EN-{^?ox%=?Bq4o(XLTHQ#v11Qml|$)Q2hVG?Oa^1dUsSm4pO-z(OB{eNIQq zlmeooip2b6H|7H1-a#)+snPGjR2Iv*+mL1gfz+X!YM8O0X!Ao?$V{t7By%Vl3909{ zT7Q`7$WGr_UH`%Q5S$<{6(>Eu7fg8OBvA6%Me(;kA112pw1&NvA_V=m`ytfPjC-w~ zfAA^1r*^p_*0gke9(NRM{|4rN`Ls4V$AcIr)Qe?a0Ki71w ztLilK?SpuBU31MqY}!3x|IZ&x&FsE0vj53`$&wC8bafigABTgmgE_F>35j@EEyLeNQ@?t0o4eN5K_YK*o4eGvm+_8$N(*S^{~=lK5u^vg0WNkt|Fc5IoW#JUU~z zGn_R5^t_^HrD(*RY$_L+1g8!X2&RCq&p*%26v<@B4;|3BAk|n@CZXjy8*(Yi$4%vy zNTg8VrcKUsCsm!s^!ln>|G>X6xGP#D%_hm`jc_k+GHO*Nq^CN2tG>am4ARTUg#smn zFP%cz1>y@T4~Vnv!5go5Hg_n#Q<#Vrl1hOb$)sP0z&tH9IxO=j2hCFJCN?|39HA z3rf0Fn*RnI{@v2so}$=%qW&7HIrC;#mWK$8LHFnEnPT_wg^3 zf)C^SBkZFT?$6YzA38xZau+qr(+@BDKYcHjFNuY}GUN|kSvy{8+ypx~xk`=1b{AwB zbd_Y)%(vJPY1dToJ4ns6Q``0Wix9W8eFt&3gtqOW5PbiB;o_*x!Qj{Ov3I9w6xdoX zhI84u^{kPB*3e}dXKMp-{DEl}?|Na5xzn^C73v5_WE>ZK^bbwLI>!jW|uk#AYlE%*$3 zdiNjJFI&M99T+!t4(26)3gKr_&X|Ux7aSF3ih-T4IoZE zz1sCR1>0nMvFK2=(AQcdz8%zQKZD|q!}e@i!8%x4&IbLI`p8LD?DB0cn<<6c0enoA z)QA}NNs-Qf;Bc*4L!Vd@>aSB=xP3-x+g2$`x-2*kX8wHYJspR8cDXGL46 zLvv&mwMEjN(%+Vliy$e}JO192zr%NVK{F^8k}xGxJyZ#QT20rg2z3>k@RJdOXn-q; z9=HM|F8r`Npm!15C|d%zK0)s!4HF!iRsnr2M63A3twWOY-y}pYfuyC}FKB&aFHnh~ zFaIIx?-F7-&*$h8^natvI=LK3?L-kn1@oN=x`ff3Hj9LbC#boxn1sX}O%)z5Nf(X- z{Z-7t(qX)3qOpx#B0OQ>;a?bDsyGI5PZ(RSwa9p)8&%sl@AlJ_59s6RA7T$jDy|SQ zv15@?g?G(4qMp`~Ms?nhMzvWAQc{QHsG>HdLA*YKnv+z#OBz4Cy_OZ|MRCw&;R%A9 z5KU`*Fl2BXzJD*B2J`yaEVq`;-THf3Iu$%}h+B9HRQbP*f91G$NyO2ltvVIb3Y~27 zy)1f(gJvo&o0u|_9EtRl;1_w!PRyp%b||X2-WSGWqbrF{ zUpNC18o01OQa$sxEN(ilG|V(gCrhV;GdQDX{~&WY+6?3!+UIJc5N96%vSzChR70i# z8>Ah7QK5sO>RROtJVk+`AXHE1I6AHR2FjPj*8}@~B#atI*hL#qtI9J17?*D#pWa&+ zn?AMHAPl{-t)>;6Aul%?#|GYG3d!4b{u0O1H{*)p`6r(9zzIJ9VJRT@MI$-;q(l3`D66}9(rwI-rnk3K?G8Tbdf@}_id#o4!}y@pEq{rt)phjK)A)) zI}R((ddFTo()#@Fed#+E`+&Y8RpFei8EA){a{53d#7$MTvM=6z>G%4UhsPt9P2^=} z_X5>L4UEYlZFUcux=i2T>pxpkny`;cI3sTErW*cx{lKG@&=32$FwhBgv{mb!C$V(01)3 zx!p&<= z2@Cle1cPXEFM@h$8;sn1XZFrQQXvmyo6>)`0d1s@3+?&o4f7@?h!G|s384Jv#wAj~ zB$?9Ufb<;EKMi6SR2nALJgn-p)8?^a2CGJR>lhTSf91`R@<`q3tL8uKA5vF~nTM#} z0ieKNnT;H?1H^ygRHvCxnT@D??6UMllx#qzSpaQ@ z5}S+BjlUUjoA|>j*2b2_9i@&^W?$@+_lK+=x5!CG1P>9h^@jv>MxBYoh1aPy;-GjFd}?^GQ~#zoOmy zK0I4?{CUgw(Y~`p-slVx)2xz2v@(W53#_6*XY~<>nVM)GMiQ;RZc_>CJYwAe;9big z;Hg>CZQOhsN}GHnfMNtvw@Gauu^7$4uV+lF%EQJHq1d&olqS~6Gh|FEnaYXgTtr>&d=wM*I;4exU8(}nl!XaYM5&ujKM*=@lh#=K9lb!(!UGE;WKw<%UCdteCfun*4dpE?LhkK` z#*gHcC>X(lebMc;5ITzcoy1>n@G$1LRk>@+AAWqBUw%$G*Q z=LDi`;QUAGrU3)hz)wy`{*Ti`jEbM0Cx!@WKx#AQ*sAVW7D6PojounjH!4~*d?FA7 zpQRuj9iyRZoe1_}?#w{tKRx>RH7O{9`1R{M&EAGeo=75C|5IF8S1eW}po~CgIN3Yyhp6C#KITlDXzX!nj6kP|J7Jx(+m<<7)ChDl(`vpeKF`Lvr@(=Vw zTJfq&2)_800)j6S>zI*-A3|!s^TYunPLAoAYk84%)Hj;+KC?&=pJ7~I>1J*Ydom(- z<^|^R)wTNMSJ%bsU`lBAR>5$+*8X_@Qj!S2$0#5Snp@QO zN=HONZ8|$T8WXNi2wA{Ctae5~hVp>Tcsb$|c-cOXA%;xGlS`5)2ikk}Dw|BMRGjOp zYBZo4>p4>*n}`IYRn_$ov9k&pos7()DbzE7g1~Ooc%KwAn>C1~fBt3O40pBr(UYX$ zH+WQ$X?>J3GOV?3TSjTmLaY7K&gT^Bjph_O8ppPYYsj#fRn>0qB1|lKyNR_9L`JU= zDz~f%ftJd~uE{)43wqSw{t;)_~ z2o*0fPSjIhf`D=L`{9#`!t$t-lr~pgV*c+3FUReFBmTs|U{GI`PDN|kNf4=>Zn4?u zF0=uKhJjqkq--dDDB2|I-bQ_BanE?|4fgN;BtCB0(Yja0d=MW(Gh1U^j1qF3xl6{v zv9f~zCoiuXWj`vOb1$dVd{Qb+MB6@(CxJ0;u-h(4ON82?&G#9*++~TtKm*GaV@-p< zPfzf}bJ6I=T37nJ9^T1Xl#t8ruJ;wJrs)ks+IxWhCACiz&=M$6S7ZD2tVdB8vyrv2 zdQW-gVwkBB8id+&ui0pX5`2kSyhXC|k8i$Fv6l{x^^6uyuR)x25fIR~qd4iYX@K&F z(Cx7f&o-9P*yfrF&1zxjnvEnu|>TArRYd>M%CI zp{yyb0;yJ$0W7T)v1)h_rnn=tik!MjPM~G{0)4n9k^0^GI+fwL>Cj@Iot%>vHs8DU zhf%@hE}go?I{${av@cU!x}(u#SjU`L@eV!G@fDUU^zokkyvhrVYqZ9*eblrw@ib-B zYgCz6IdJ=C#TVX2C4BvXg;BfdpQ{e~<}JY|&>K*zXd7O5j_qCSZ;l-0#&eiu`THES z^LO%#I&*6wQtJZa806E`KcP=fBADg4h)_i;v)v*n^yvY64ETCn8rr`6yvGz_* zVZFBT#`cV9F33j6H|P(%lWjIJMsN5V&k9Yt3hpQm2S#4snC5R|OjZ=h)pPQ_1!#pK zI6Tc$*qUSwwQHd5IodIwU3}0IlK?BYpv1it274EmdFFn-i!Cww%*Wf;M32jMUlQOn z6UifbM|~PjE&n^>{X5jtQUCh*3gpVJ$$BuOd*@&OTfmOdKD5a+s^gE}BHTWBMjk=2 z&GF}#7h0u)miL~1VUBy7FLB6TKTRD+^oDn+QAr)ItO(zfoUSbgivOt-mJeIKf!6wS zncnZvPtN23=A~j=`d9tXe(F*dbUaKy-Q}77oL=ISmvD)hWrgEcqPE$2G`>I&K#Wc2 zFB`|WeMIVjhgWb)7Y)F|P7Gqk)IO(=CZ{L6!h*u8CVE%#xkq`gQP@M+vqvRz5#Yu0v94Xxu)3NjF5w`Qz(ktJ2YZ68^(RJa_&5|u6;~~TQ zFz4;=k~jd$2|EtA+vj@!{&DwqwTKHe?<( zA2(i~Lc?^k8>vyLosf}Or6WL8o{E9hmZ>Zen^deS!mB~@-L;+YdxbR~?oz9@iYA-9 zlDuMeMzp|vFe{Xc%NA6Q2*RY7OgtG=v!RIlFJ*?g)@-ONCyk}vI#>B`$T z!=$9ls^ZRWL)Zj< znZ;{(2q$PIH2(`l2oT)vB)fv&!y+p3IJ+Gubar`N05U_Dgy5m+gaD!%Izpq_Sv#s3 z2>y|QgLM)YKO4xq;vf5?W$!T7Y>Y6lM;QOBx; z>xzA856a40s+A_$?Oep$$tL;>UbR2b8TZylt?HUa)vw0o`ni~b53XR(zQ_()4Uz~} zZ#)7h6!P>nGwLCt_O5=EoSP^tVk(~JBK}FFVZ2@I-zmtOg>GZ}Q5i&|BycwDjwjWo zTZ}jr5Bw`s{9^QgT46zC?W_n6%(>d5LW;C+1pQj_VB0hk@e5xkqre$pV1lvg6f!yX zd=VCN!aiBL72VkYcfxhaX$F9~LTka(u*&^nx^S+yCVAq#SzCgQXBXkU#n4PY%xzOX%`8N))ej0E zgD2@iPU*FHZMRrJd*TvsR7jb}WBvy58FbGsahKqS7j<|e=YD2w#PhSp^!u4Ffz_Y9 z8I%N}wR~cSKjAavMa1iU+I;sA7=Z0R0<@*LhP!r`MC1X*5q(3&j?7W)h6KD&_XpQ=}kne>r`m6_4kdLo7!le2yUr#h5LQ3wg zB$pEu<*+j~L~kbr-Aw95sc(YZN+p@&h^{*IAne@J+Y)>gJ{{kB$ka?Q`-j{+1$lko z*p*?zaTgeIONE>?0ypwxCy)?F3A&yYKi=aLa-Q*{%Z>H;UENj&RryoI1Ep1A!dE&s zLe;h$^*vEL{R7&6HU?(QJ0XD9L%684vgD7JLBVf>7A%SO_)(SB@GD*_$Ew; z7rN!smhC&$7Up~TFg=X(gZ&J>lL`Bw%hFPQHCifn-ZTBOkpMd^BAx@wc>&op4&Tsu zwER#qTY1w0W6FXLv1$IHe4+DzLWI8vB%@cw7V8f>%ZZNBx_du-lb02nXq%&2MD0>+ zab0saL?D4G4&zaa-i8~6M>fG~4J^prXG-epDwK7-HBV`LL<#iD5OyF@t|0RuRe8uzd|}DRc>R z?wPPD4Mb@q8?xgBA*DvRVn>uhQ)(dWmhp16P`N%h%qo%vc?UNhcDL6Br`SLPqwwcW z3W^cbU#fZM|IWyN>;K8{c=|I%EfLqtZH`a?h&=gO*4|4YX6ttpN|W0+2FEvp8yeZn z#SkW`0;^tStc)~_+-m&-$ijwE&}vP?NVDzZ8W4%7L;g!S1amE4?v=Uq2X+I5_-f2v z#!_WKXMBCy$3ADv?j2J$bCRE*aZ+R^fi(c+^V@NWgN>y&O*H0P%FXaMVn9t<8sn^C4>0k%inXQ$timZglW4`gs#J&NIw6Ui- zl6Wyih>_JW3WQ3u;y*>lN7z6LNhF5$YV015OuijD zOH~AOm3~rq$P*je+y&MciBqPzt{wmP0|prLS5hBjW^9}kFKiW$R}PS2(l0)86xrtD z7Og?v3U23!L)J*+^2?sT3s!TecN%z?@e(T09IMw<{i#wbxZ~*N4hIlK;TT$gov|G) zma*CZ=geW75{=~GVO*~4hG9F)z9nlgjf@uJl!zRt-oal#pR$wm|Aa*?9i&@jGM}OA&pcfyMDwepa^wf8dmR8-?2i zE|#nQG3Hp-0-=&;gh#uATcOW0n!35qDQSOZ4z$U(H(A);gDgIl7ST3dQUFN>!j^>< z^-}IVftFXV8Rli4U{GZ%mnGpf<6oeD?0Lt+ifdzg!HyvSmlm?^}3ak~>vM zTFKk1RXJm$VWJK3j;Ty=R`OklHBJuv_rcPE0}e*JHJkw`Q@i*MOK%aTgA|>4Y_TRA z5wHA^)C(7yTlT(Ml?(p%rWsrjeicgqDugNpb;4}~{`$}q87~xZY4!P%*dI9^QhSb9!gR0Y0`OV%*@gr%P<0dCq-(hJjCAD`Bd&>jM?5*iPMMy#0oE2E5q^B%@TRCD_$GY(7 zB+&~qF>E@G8RIRjlzmZCj}ZB!W|}V?Dbxpaf-ANw@`5kiAdu{ke`677SydhVW_@V_ zFe)%!;oNhUP1Sd+Hhg6e?@7CD$vCqnvoda(xwaj|b?KCte1@jKig%+MVx6@^cdI9b zs+y(x-G4{!`&b*R)Hq!xwrz69{cUAyiREDl^uIbesXSovpCac<%I73Z@)A}8sVFF6 zilF*W1^^iNnGheH|FKc8)B+?~@L8z9$M77Hy{<}*lQVYHQtg&pp8?h1L+sQAuyI6> zDpr$Fd%Z&HT3tSO&&AJ&_rpqGI=m>Z?yj%j_FYkBI1BCY&S2?~n;}w4L%He~ytx!_ zXjvUbq-wn%u%QiT}hqwk%qQn5U_0ueFJ0V`O21*tMZvFhniL{obx*9-Ookx2e+NL zUgem)o($|6@pm)W+PrD5xOk%zEwjAkb7ER~FZ!14exx&ND+1@HXmWxeuVZ+Cnm5{P z{wQHrB}dAk=%3yp74m|cTI-RKnchR%q>o_14UFTm_A^sxxpANoP3`?O)OvA&VM!Qg z;616MxRFy4yN_Bi+A2y@^be5gH8;U{K{=~<*xRDz*MjEpe?nSuPuPB*lFQoNyCIt{ zXI!;Ly2S5ciEg|1IP1 zRil}IezrII|CyhtC@4t^#aJLQ7jrXn2RCbDJ69%CJ7ZT@4Rz=*pMQP%5~I2LX-NS; z43c})x{ZEipKKU^>W{{4<@#2sMP8UQ+9i+I!e+-VW+ zNm4BksW#*<#oHWSXV$D_T8q0{fRi?M`LqzSlM7`@D`H*TtCT0OM9+03n6H+U11K`p zu2mEURuU#l*!kzpY|nrGo5ps}fx>Ck$Sb2**={biU(TJ;Uww(Qu2Z9H58<<8V!O1F{M;G9H!22vk-u;|`tOB=-g_;JeY)nuX42EtmdXbVr@k z%7yvLtFT5&?N7ecAAk2;Unl<_L$}hC39mWlVKeWNkn+hEMfP3om!nI*ACF4oUO0=G{CQmdQrX z?wYDK<^>I)gRRcm8gSFkWA1k5b2KWiV5T0O@EqAjT+#UFpx0H^_2RdF9bEIp9l1#8 z$^6|Y4-9FDVycjpQn$vpWSbMBQMOd(e~yLN^e+h5Of^Th!CSZLbeZOIA+>cR;CL(+ zkl4wPbiWw0XIP131Us9}vM@=PKgAF7+Q0;KOM?{0(~(o2F6UApG8UNANu;DPaajhD zvMkH83d;z7AUFteVu%GC*ZR}sN$Z?o;hz`a@jiHEU+|JTA!rP8UJUavAuiL0xSY_t z4MtURK*7R3qi=SFb$MlhDU^a52UpQwc)qt4)j~L9;Kh#vkvU~ zw@6h9YUQ87unjSTzk&-N5dSoSo3yo9IcPz7ECN(YJ4TfRR)~PK+NaOV`IX-;=_j+D zYR%EBDbL^gh<IPecvV#MjM zhApBSp^Q3=1f`i>>1H(1E;tsXbqLK%RP_6qTscOE^yG*|IX2M$pk-^En^f2iLnOGcoSZ@EzlG}z$7yuK9Ec5 zI#8cF*!O&c{co{R+FgxK@EP=SMf{&hS0g3_Qc%KY#U#KODY5=RfeEq97zSnDkcUkx zTR{}LfKi5?;8W(~?c5m^{2*o+MueL13~F_#n(V)^51{Q8Ah8(OB*<(>my9I=nBh}1v>M{;&EgRu^Wo6 zK(J^TqV8`3$mnB`T_Zfv1^@P~CUn<7j^ZqaSe%RPdduZ+eTTcWhTj#SMf3Q^EHo<~ z)@TA1E%sQNIaujlO=zc6khaY;=n1}0NtcpKEzx#>_Kh|2go^1AkcyojjRiF3ylD{6 z7<6(%qb8CQCAfSA)S<>4-6}Tq0#!S1Ky_fcT&Rp{6=(b{loFu1_8X2eP|(#mQC)q`s3n;(F{ zO(?5Wzpc2N&Yy8_FAbkMF1sJ0pWE}YX1SYJ*PH?0VmX~%{|hh-zz4PCP<_?k;~H3*Gh~xZg2rMnF6k)2ob9jWSHi!pKDw4g&^55?{(DuO*GYm zA~mO!vJY^6KP&imhYAc{y58%-)FRYkS$ONIS^L_eud)gMXhafpiUk^W^>@f=Cuks6K^=sgSRjZJ?vT#2UNt~o>R6CCe>iDKL&~@ z21l|Iij7$08|jCEJ&RJ8^G&sFq;Rj7qHLQ*f1Sj$SD$vg*FZfsivn}QV@ z&X1__zcd9!JO0VIv0csBhq>WfGmsw^t^ zLSWI>bVA<+iszUEK}x8x-|egXeTFe1pEfJC&3NAF+yw8*N|}*2ep;prtgKHO{<)-R z``K`p55`N4`1?evKjRD-<|GS$RQxr=+{?1&Cg4nd;VJH<24)3dm>w><3rX8Wqv=zY zs1~6e6vfE&VO)eeB!*E$0gG?YbsD6TV1djn!l01;hK_V2PzO)q+Teaa#W7nBZX&JE zu$dS$#0QBZc)um|?CtWO#1l2a6ViEhYD38-*2NP>`r0#-yD#yOkcCU8Z^yEsDp8(f z>uBdSWW_hKxS)V@V(@^|G?dS4#TsK}4-9b4hTB_?6fH*4ePcSwNT<4#ma z7iCn_ueV4$^){BSusGtm2{oP=u2c$<2!XiD2>n4HGCen0Z@8bz^GVVl&`%2NSaQ?x z8u>-@g`hdZ{Z?|0Gk82#@J!a|pK>$D^56fPhSZC&h`)V4=z#ve*p|4A80i0=kCs3; zGw4-&iF9`4IUw4Lh@<_|7ROXp4Ow|KDp|MgXy2@E&`SU+ypjt&S4qk(3_KP^2+IoU z^)#l46MHZn`5p0XuM!Ij`+s$UK?E>=?;KGSn7`TZqsR^tD$kPe*XG`oU94|&Y*Vb? z#fBO!u7!Spq8;Cm%QOcW3tih_Dg9({S;y%2X-clTvYA8hi^lw1+*;+)GgLVN+2mO1n4$M_Bw-e^Ni>24Cb3~@9l8c zH)xB~{A3JAKcfp8$2`3=%Qan^4QD}l%Ywyy-;xcPjUv>Hr13x<&+9KGg3vNXG)}DY znDQEsHjKWalJO?tj~6i7{nGIffhu4tM0?C=G{!P)8<45%2LDr7->u%RLnKG^o*hce z7VoK!uO`A2``L=LX%sCQDu1|58Zh=fSQ4Q|+0vg|?a)P5wtHyCc;M2KLw+YSQpT(- zX}$3%z#@2gSwnv?8fpwC}3Gn-@3!r_#eXLN!l4N`yWGD1YtMbTrS?&e9G?Rzb|?CV8n zxYnb#?=L|UEVWRCPR}XlImeNo`vQ|#L<|(5^Ul1jy+Up2l6DV0UI*R8I>h0Iso6lY zwJB^wh?8PJPfGUDKF$Bf)msJS(RIYg|2YW5goKC54WaT%q~ebaCJxSEi=KEQlA^UL%P>)wd~f3<)! z$qO+~=H=F^%0FgirgA9UJ0oD~dsL!(lQG%qeo+GWn&g@r4R!#^go88KlqUU`sV()o zkRL7n8eilAeiaABhjol9$0c9!n*yvVgdO#!gib9vm^Du>tvbTWxMJFrX})!xkwg*d zj?)?c`+(~Sy79h?M%O=lzxvxm=Y0xbF7woJ@qHh{1L#lsNx1evd^TdLI}>fk#h4)# zS*hn6!JV$#OImpOb4y#LRxHBsiHo3NGUG`5=x$=$Ld{3RT;Yc|Q%Z{P|8JGI&!g%pKy%m%##YW|sm z3A7MT1R;HCc@u^h647bZ6$2((~nt6{XpI@*ZNx zUk}e=x+v%yD*MMwK?C1B3x1b}R*&Dgs0*m=gY}a07?feHo=>d^PK>vNpVa+#A1c^2dVuSn9&|YV(%^ZpiL-HBh}VcF(X|@wPtTB7q)1A8o*%y@ol+88 z0rDc_KivUK){&enhbFPsEez~;Jz!gb)a4z!4TxflZYz@rX> zI2?U4O@m4!=e-TSYv5i0OeisSIy%PsZh^v^o9joK#6sUy0>H63q>?-o12 z6d~-2ch~^tn(QM&G_PH7&_PE5?#PyDzToB)#$X;DkjoU8Y(Ux*D2cyz4~PlAY@;d$ zzRPZ>IXn6P)IPo;>|;P~o4us9hyf|~ff2NoNsokpc6>?M97nTO zGD`)P$8+zlx79Xao)_Ek(Ql<6wwkUy_5PU;^Cs}2Qg@th2f1B3c#YG1YVTR5gD?W@ z0T68gm5q38Y&O>BpX5|E;fnLlw+GgQ2ECli-dshJW;DZCQr9lq{Bxt8^B$qWf*hu@vTQCmfU%JAtOSXOy`NqFVVMEuuBl{o-_)t$__cypQP{ zWG$M1Qb=Ssq(|>NRw|TH& zb-x%8I@{?R%X$aq8ND?0U(Y+9E7z-4D{`5{j(m!3zW#=3FYJ-znw^nnpoL%6+QjT2 zG{O}4G1=x>Z!P61#^x^D6n;4oQ|*@~*qBH_cRHSz$aZi^ynexKSSCA7&*&7J@u9X= zxm=A>g)y2i$yDOfD3Zdz2@apadY~_=qlSOZcJAdlBI^neUbonp(=O9l6d&91AKUp{ z-mR!XTU>w6Y;{M;VtL_?DV`uo$8OZNzcqk#pPs)xbIB!{f z~IlO^P`|Sxtap>1zEyN_g1RHx1POW z`JGiJ_F&PMY}gX!HT^v%!8MZGEPJ2)GMGQT=e`t3#%$cWokuoVV`s!|CfgRRX2)+q zyiqhm*j&k#Kl+y09WP|Kf-1o#zQtmsW;Gi>)AH+(-z_wcH{0Px-S|0fN%hAS{x+I? za?~C$zL>8Gx$OT(JCNOiO)`2%rAe=UjW{8dObfL<2ALty{RGLedG^%5`$XJYhTwso ziOgB87na-jnP_A0wRd>_{!6I%;5y$~PYpUP{dQ*oHbC9YLqf|gz6SH-W%dy9G906Y zAC4P8#MV7GsKSRn90=zv+VP0#D#e-R?lg z&!BfBcAKQSqrUX=qrs1*#cD^t!Jegc)|ei!exFH~t=^7w+VE<}lLw%FG2G#W!m4D@K-m#c#=^<2)Jk82?@m{D&k zN3tzEVVANXDR-HwAeBxzkG&-{MJZp(F3wZtUBcOnrIrW$0U@g*GlDBWvt^1cbbGG= zS&vWXRB1x4og)vuLVm7Uc|Ino6+%E`SJFLd$IQJ8y0x>gP4qkEk? z>(AUZNBpioKb_+>-&;k=H1NQ4788-Ap7V&1E!$?$pv>91E$z3``Fa$9}C+n0n=#mj z=t@SGXLtqYmyJAKFLzFj6hMF7Y>S6h9)*HH``Eb<`@M`Q3x!;AB8y>HFd;}P&SUEBsU)CROwfC?_Q=qg;>=#hM$q z^+MQO@KuK~eswd<1yfbwz{T+ZdXTGX<5r9)_LA~0tm8_stLoDiBEZdv5Yz2RO;ajZ zI4rikE(RVTQ0n~O5)iBZzfXAO+x|Tn81~luZ!toy_}@F&ZRdYwell493W8R-m^D@i zG4+{K=u7e2ozPE9e|K2+O=FBpb2to(jFGTM^>JPXU?*tgNxwYwDmtnMGut1HyO(Nw zW#i8T7TDbT`;dpYqaVRFv_$Yra2Rjl{(d*ydKYJshz_NwvWP7nW~rzzQa=202~oNz zN71-NIoLQK6!x{f%bP|)w%P8b+!0++$NgP2FRLXtM5!^k%A#;o5GOZo337TuPo%4s zkwS;sWZgf`X4yZShT_px{&$T>QqBqwt>8@$P^UCv`iM(l?vhO=M1kzV|7HYwb=`7@ zb9dDpFuVV(`(*j{%Wn&XaqI(WdiOQy$HFpd0<7n>1YJU=Td1$EIf^Cy*teUWzqaxm zgkI|l+-c4rdEe?iHlOLgf26+sJsqU1&k<|-HfWZrkBJ}?fV)=}r7NZY&1hFwp^6?MJh{=6Wh9Mi*~?y02kbehplfPg(LOJA#lZPut?j1yE@seC!?ML#79 zxdRja1w5-26peBZhZS1euw0X+ZZ1|v817mW)F_K~U|AMhbIg*RX`x)^Ul>df#?H7Y z%~25_F2SWe!P`FAURp0~TaK$ks&@=O0F;avp%!g%1pLg|k(!F!mrniYm@J0@v8sKW zOivx~D@OsPbcPE@1NLbl@8+y1)x44K8S@5F#1R=6M(kT7pVe=*n7kl1lM#NGpU0(C zHeOsMdtJ(~_AiI&CpuVJJ@=!w9-Z9pOYC;Gdg8Y22A-`NjVA0zwsVxN@Be0mzQTT4 zQ%Hf{MFIn>CHPM=SM|b9oGyh)bWo=P{5SpbG?l-rx~S2>tuNBZ1P(zJ_kBYwX{Hk5 zoZno%m|M-BI%f*Ok$ICDfr_U300WY4S}dae1TwQfo@IGoWC@5ZFQTi&+Hc52-?NXWu7UcIS(w6RT84`SP2vTpoFKAug zTC?Ny-c)SdTwe#4Ov+mP$obL%q`e)~!1j}I7No|}ufc1lav{&XkvaSMcR^g=jncLV zmen1Q4$#aTa`Mv>n_hl3rMF#&R)CZQVJj^?}-Tz~@bH?<`{zLSIm`7)arU&VEY?inX`tPTq_Chdr{b`8QvlsDzUT)VdC?s4J>z(H zT7o9JI4Vi0(Ayzz+xA}8xHdH6il%W&=JS>5KE(bgEPhVDGcGzhXbzWR#TCDbDeuu{ zyx~tTm4vPq1eY$xqKUTTj12m2;JM2fT9UnJ)QdVXkf4k1WIL{SSa=0>Sa^P6grM2r z_Rq424)`J`82sl;jK|Qj9{-CNp#R6+i__!=rnJ0$R1Tg$g=8`kb6{v_F(DxGF}xHi z%fdt##1K^H257!R(qvJU+ER>_kHLMvO67I^r-mQ#Z62EHa5JmmQVc69AEgPKg5I;7 zv5zjfTiYW$k4s#py%p!EY_+c+mt&PIr#-iiJhzVgJg1#UiM=8E(o=p6xM~=5dk$j) zn0m4fv3Lk-PjmsBnD>~XjBD62G+vsKo`u@d2ls7w%E>|FnccDj_~&YY-e_YM1p~{~ z;%HXB5(cr{6?dM40V9^1n|k^4oj6=QWRct1QQ{s(wRlP_YWkY94(fxr=4!eJV5s{@ zjJU`Z)`O_Y-^+)+@x1@Y(iCuAM$-ENzE)k1**9yJJb&uB{=syxar`XGhWVQ)P1evjNR)8FeuLOE+`P1MQ7X z844FBe4vk|)f+XsW3oaBedBp`+jmJ+E|k+P$bulQ6vfSHq^d^6>cxPY=oQrfx2{+g z_o%U{u{5HX3dYw!tK9Ha(KJE*u*ES4ilPR*Rx}^$Bu)8wK~go1y;%qOd#K=^SIyWQ zJI2eg>%;c%;ocp4+6(Bw{_)8lpTlyeJe3iye7??K;lW?F#E5uA!_#lIh}Y}C#jVKH zsdDl-jPby0@hiv}wdUoa5OkQ?@u6ZoY=^`}F$Q#dwkvMV63-{U^WvTELdAO@r);&g zD4n1!alVl=l<8WHoKro-rd^7Q6U+paow!`cs=A7?FTZFK^5ZiBYJZZE>d=!p?uI$l z^N7^ZksP9US0~6Key`j>_14gkT#Z@$lGu*)C98TwuTYE$BLy7v*D@zBo*d|C8r45)1{}eO>iWvIbu;KL$%#rTFFUkVQ!h7p`#}M zGj!{>p*hJyywgeqz}g+U7$)3PrAM@CL^^4|lAOt%u_s=wLL5J7f-;t$96%hpFqvf>?K4Or9HWG>XZ%FL4x~fo{?P@r*s4yEd@?jL z2%o;wv#xbJ8z$)Cd z%}t-Y%aMto2M`Y=2p}Fw9n-AKUr#(mM2T()E9ZtF1UWU)?TV`J-~`{x7Nkm~T&vmP z$WKtIKV|||4UJE?r0YpSLYJrlbOZ-|P-0z-yCevPG!&#_oT=-a+7tz5iPrFzxS=fK z4jR}YtmHeaxX8wuO}rm)y5=H8sW&J&Bn-mJG^#b~t3hTg?i8JDuV^_GRh2mdjtb|= zd!$q}+=J8eY&iqm4fjiRdh|!u@x>HAiOvR^T_=FHu5@x`-qIvr<&lm@3zXE2aL^07 ztm((e3}*I;aIPSCY(bE{^TS?Bi{54}zC<|W2fs%>6!T8!M$=H#!P_C#p1opM&=p); z>Z(DZERZ@*J$xUHi_EN9k^V&gM@2336#nH`;^MQC zX%`TZTOXx=6(gsvr+d`L{6txCywOUT^_09*g4BCB5GUD zRXO~(XwxqNdMBtL1In3Ubo2upz@U3YH(K=`O}#b;S>U<_Cuja)*tJ}w?w1q728Opy z-k|N-non=hIdOe!tjb(>h^yut7>X{pSAZ#@!EtNXKL#Fh$EUWt6V-|3x55znggHQy zrV3Rux;nNE(H42@DC@w6Q%vamjyemy2PJ}et!xcqL0Tg}XDB+^hnQjmu4zmJkD574 z7d8SEEx#Ae8;2?D6j4fqX@bNv1Dw@He2{TCqE&BVR)L1F`23SkZJ=4*c$=cL6EF^c zQk3qYe0{`bVs6yta$J#UhAUg*1WHEbTq=CTV_iKhTIC3Jm9P_%iEOq`hrcND6ayI# zeUmR2rLIg$!HH}VQGGOS`c9#Gc(akHd>$l%R|!(>hfBsnPst&O5trjhdE2wnD&Zb&f6UP1<)-@o; zos1Pw)0-Agh3@UTz{2*(*SwK*oaikl#nSRoTSR&A4ZZ-d=iav{@DLY9?~p|iwgdvCwmZ0D@f z*lW7H6q7R5xnyr+?f4^VT{(HKQr((wb;(R7)FTOnH((Rz?4vzTT zoI`}le}$cF-g$CH9Csqw5q{kRDw(>dzBm)87G%A=63a1U%fG#|wpt{A+x&N-k8Kd# z=n|YEJyJm1@Nfa(pd-f_#tq~~69qju#(}^tM>Ww_JQZ@^uv;bZGsiipI`uk-%(kV~ z>656GuFKC9VPjZg4Jbmb&0M)$_denEG-wRnN*U6hIIL4?`Qe`fP;>x z*d9ku!wlo>{t@G&Kr%(eS{eVK*(kv3<-jYq6RpN7-Yl^&dq&mH=Q!-qf zZ=k@Suy$579chMCT_%F%?Q_E9>c-E39I+jYZ<1ZxY@2=Qo5v7gWvP`+fmIbA4sSpt zSZt#kk+>%xX_g9!j*`&*5%u-x3EV6OA?YL|g$uxt-}r~pi|$k3u!(m!iE3@bB6T*& z$N1+fYkwgxMlbC=yGxS8n5vOC1b$Xn`lRyo_#KC%H4W?@9rnK2p5lI1xh|D(adhuw z$09y5HWV&^cT7h=ll2TC?=8k<~2#b#>XN@^VLdQ0%5oM%2>RurEs-G^{1`TTJIXb+nwIxJAq(BHl`ZZsp1NhsHJt$s^by%G$tW2AFO%DPlc*&-q(qH``kA z2s?eTv9g(T#?EZd7qlT^`iR|~Pr$_S?>haP%|WlShB7?+4;m#SefUU-4~z<3PFfD6 zycWAJ=7cax<{zSM{|p@ri%PkC-st;#zxos5sHq^S(0WRiC|mz**G^sRVpQqfKZ?sW z7#(~n-{+5o8bn1FO6rfkF!=1dK+Q&amWgwMLWkKDe9S{^gABBMD=8RYWB?u@2z=_1 z$`FceFM`qUjs@b|opuTSLJ*FPnM6ZkPF>|1`Dz-CviOXIgsHjRN~`F!DM zdX=`3w;SZWjy_QKh2@4Je^6!wk(3^Zq3hy9~w1+;G8_1L!NE!WH@2l}A;< zq(~XAju>tx_Y`~+9C5!pOuLIdqHZK+8Xhj6>r>fpKiyS9x0XD@}oI**fr;TP@_vblRj7<`F-S#9QNT_78N|t;r|4Vx+{RR zV16cx$jtiO_;jpOt^EDE_dQQgAyU`9r0g%rVo_Pnl!l2m{@IYq($WK)mGtb1|G}Id z&3(u7eH2FaCNIxlwB95+yUu1^w15@c&fxru&dJSh1FdJ{$3PZGHothKuf(6i1i)Okp#R$%YcMU+>wav^6MXN%NR>>^P^bikpoy>AVLHoZcDDU(>!= z_(lk^{$dY3ag(e%@IYEXo+mv2%vGGvhCR9cSDz!23A8?tAeNkbc(rz4vU+{>5a0i{ zz844aZ|;!L^zMF*)awU@tfGk|U(ve5>6go3lM>;*BR)7}86K)#=p90a)bFj=lNG2* z;R`|o&ox4FF@})+vROfQ>8du+su#qdWLnjIzlTDl|uCt7ln<)>K zNs2e31RuIma*S6e32Se)j05)jG-n9E$VYctE1uyX8&hqi(WqC*I*8I0*}6U!Iku5R zhNqr*oEhntz;u(seHY+S!m$tY4x)!w|9t?GiL4I=_C*Qg!%~e#)qF%D9rz zq*8C-3aik3XFCP=Z5ghBqv{S!Pz?F)Kcdj@$Bm9yG%&Cys{d46EIS-P+}_>J#@@)} z|5f;+6zu*TA`(1$KwUkXK!uAX#G?#S=DvYpQA^B0q^g-aSP@X-2w3l;QhRB0Jj zp)y3KFA<@+0uqnP_3N=9bhDrHXbfS-*AR#j9f7=`=T`UnwlY`jII+-CB!QVwUlWA; zc98#<>#{uQg9ZJQ@y`F<|KV)FzI^$T_{NW#sOlx~|0AzLwly!T^mKHDnYPsX)pazK z^UFL?#q-HDY3|bO8rLlz0!?&~3wD(LlKdZfU3Em*R|@t-^Bj@CA2%;BhR73NW08?H zrua)oyYW%)vmr-svpe>NjmR*SObIv#+L+5~gV8p=@DVu7U&9P^vJTGhzzS(dE@T4~ z7C{1*zbET2Ub{aOV>LJ5(jP-*|1q4JNa;B#b{s8>X9}? zZhO!!s}E(!n!Jh9ljxftx^qn)b<#}IdJoAj>4=~&O|P^)%7X=-dq$@J#Q6*_rN!50 zJ|ma!I+urN>wYoz5D_oFx3(+oM_M@uIs6b zNe%*^Fq@*B;KE8V`MDYf4cnOT?A0%d!#(f+z zh=`UJKjsJ!;VgfP!qZMICh;79W6eiBcDdL9qEx$VuiChfN}YI|-M8pU0D z059fw+t!T+-%W)HFLGb+Dv!^GnT8&J(|%Lm@ZER|hJ+ZZKhcBXehBX}^kx!2uwX*T z%13%gEA5r&*t4{;@WO_Es5`9wHr#VCDPpCw&SGboS?}$@?izD_euhlf{7UFuZ316e zV5~R)uYp}+=v<{R`|C!{HYQ~4%wfjL2*^{wX2K@Mf21vTG2`Om)+bC|+^F)QMdI$8 zRCEb7aL*A$4#8%&-s{`n7b7~cw;PA|i_(uZl@(6DAqlqSjGZf;M~}J*S)M|&*Ncw9yCuR*8FeFA?E|yweoHhO^2T2PQn0A)A0Kpal zwv>DEnzvv$na1Hgg31Jz^HKBlZLb3s0vSDNCu?i-S%2@2Ugkr~Iupi}b=-?%=`3qf zzgC!Oe}0m*iE`>^C@l(QC^!r37qI)7BuMoz-o>ZV?XUkNe*TxO=TEd0dZXZD>0r%} zr7rBobsV|~7tgS?whe9Z{{SDez~mddx)p z%JO?;>QclmT|s&DECq!eg_N(0zi%E263)peVOjY_nqTJ9L_c#;q=}G#?SpDRVn@&_unmojGizyQsWSLZjL=yXN7hV_oYx1ds zR6hrKf@~Q^y<|1)434V<`2f>!rjiFW|Bs+#_#yNY5d*TE81X%SX&;QgD;eS1J6_me$L4TDJO|PJxpc znB67D&-u%0HRt41FPh1Hc?tWbrpA`nW4txW77y5{EQ#L*Qvz@XW4C(4u%) z_QJK)DM99{5Pl6-F&s-f=nL9bmbS`COSjr&oS5wwUO!BMj&8tOjh`axg zNIO0nPFSDUyPU>|#>n6=GgHqH@}3)>=0rd~86JTufL4a|V%>Hm<$g5NyHN zu80>+!bm8lrW#+nH%&n1TsE#gCXNgh3#%YrpDST<_j%oH8UnsTyJJxGuQpYZjDZV9 z*(k9uSE;oA(r5(-m#lXstrH5!Un$TBzd)XtP*L7675ked5>#R6LAg;G=l6|ccsZXz z@u9UuoL>TBeUsm|zZ))&FH|JoEcI}F{V_-Q8&MZmL1NxN-^Ggp(+V^^Km`}cOQQ!h zJB3z%Zc!w`{58xK4YE!RgFmSP8MVu|Z%MtDM}{OZz>CaZhyZPr(G(HpM@n%tT3Crn z!9}++59ZIEJ%?<6aVH)Kpf^wct-a(c2hGunK@mCdCtL~Q6-%ns{Ljg89rSmLgMx{+ zR4R(>X^87%P*fBJDu`j@kX^QwN%wp9aZZ7;KA*G_B-yGE5(PPmr4>g_C*C7xiNCr6 zxS#^&4eyKh%?C3nP7*HnF};viN{lzSkP-=l$scY>sWcE>W)woFJ}>l#k>u^-EGoXV zUvm@?$7c^r$uZ9flgew|G*SGd`oyqkqs_r>Nm#oextF_}9{7x=#ZRuoxnpiSEgR)S zm$6ov#2cvy`?`%L|G@T6523QLmJ33Z52Xvn_#k|#sRNK#_mcK(0@4a$e2LM>3tULa zUyotYWc2fiA!4UA(WNI>=QF{w`i{z-{NYoc+%lo%-^+KR{M_-Kl-g3ew>z#5SVwOnl9wu;9P*W zPQvcz4DtS+etNL((FJ6tZh4aL=LAU$QReA)p|r7Q(@oV;rdWrGDGr?Eo>~qrQDj~{ zos+}t_4Vb~m3~EJdN($l}3AA3hy1L ztnK|HeJd&Fa_^T&0-t^aW2U@5(^GvngU2pdErue-K;-y&!gM-JAr;qt#1$YfnGUMT zw%Y7C8L2sfVP1Abks-~txUjRfzXHiDna~&Fx=I|4a8lKVFkfxdw>xH!9j(4>vSg$@ z@%CnEgKc^swuTK^J3A<&kQ#uojOM=ui~hm4+Cm|m{KngZ;$PgdX;!lz+3kaBKvukA z^t?2#s&yKJA2*=9nyR9XZx3J)OK$3Tu0AY96K1+yoo_YyKBm}aXUg};Gxe%(gOZXr z65?2RytO?CFMQfVr~g#X9Ad7w>h+Y{hK^-xBCYCd)N0P$a%J8|SkA37HE&`S;ZKkh zg$)U9Sw8AnKCb#`torWCzAy=JVc}k0#8%OSgkm1C+-Gy)DsrCZ;sdfiU6I?-6qCy! zM^@SC@ioh@$pl8)>I)tmJ>2!9R+lpn?%F5+ppTWqh8RVdgTqKAwaG6sc0^`hDPLN_ zBnrm0X5*#L$D)$jKiCyESowawdj>wFl;-NVpIYcpV4X<{gcdLmT{{UQgB;n%h0G1a z3syrXU~V?ZziCCje**QnF^yja%2X0X@U#`9cYS;WuhQj)Q0!8WT-ccSUwA;MCU%O_ zFEA+%T*EI9XwGb?VI%&a!O4pv>G ztp|_TlQY~H;iJ`>veqqAb~rv#M1;8m0er@>{q6~z&v~hbx4>x0F~-bh0>-tY_=f6agJe-ekM7E6#J^f}AL0#g&BRAmD>HHp=;tR< z)$KQZp(uQMGij5#9}j77mgNx>CWQ$VX~dFhleyqt@P_tD3S~m%vrS3A{*VQyK8c9q zPxzfjXpvSi6*obNKY#GrFq)cD^-#yioBcg16~M!=25=-TyW5U)M7C%uppu}^qar9? zpi5qGw`0eXc#_|6PRS%04;M{CRkK-%yG1@}{I8`?3V*kti8g%qRYnm+hfV)Aeh^0` z3{AqfhYSTH%y{Jrx4&v$zBw^{?YI$m!4j(4N%h%%;n)61MGR=6e2RX+cjPY#SvMwM z;-l3H0D36#8aPyJ&(JvD_7o}gP{WhCRF{gNHWWm0+rmY7DF#(VTv!I+%MKNw{aQ#$%T*IAOD>M*+}$LFFmlVpUbvx_`u+O8 z{nS!H#%ay-l#Z?5h%?_#O-ihQBU;nk)LwcT02VqWeWCUr^jqkoCnKYHV##kn7M6I0xmp15DCL*x`M#oD@Rn(p>g6vQ%D}3K1e9hGqgyxiJ58yR4 z7}L}rQf^v)#^=A$uq}vvSxuTdWncVR-K0v^f=z3^ItlFpZdS>@s0B7p7gu{N7h&R& z{w7(NLaLuc%YHjZ@31b1F2fc)+&M2D6mJ4#ZYtuQ+Ta&o>BC~WWDVr*1&TBS6`(@( za<3BSNYU~peY#R}<%FrwIJ~_*M~)m)$?0Suz90g1vPt^qv!d;wD0CDU z)o@{)Lh9F3y%#|(+fIZ=!vPu%1|ESKTvYmX~M>y(DEWjCMNa}@?v&iSQ%YZ zBD}KNJtP047_1b-)~_5(EL7~D1vr?+UXyhO?rclN^0YC z>c&g9<(#~1csTJXu$y}p?}p9e%B2RcD)|t|b8PK^PQlv2P=bovEqn*}CuLhQsnE%v zXb~625JK=TuvaoSF$w+B&mqNRqG8syEPLoh7b%Q*3PB^Gwoe8?!cB+UQ+Zxj%YGF6 zDiO*kKC#JwJSCl!id?*~fF6tH759H}@L1-V;+MP~)ETuDeNWuC_ej8k&Ml&hR+`g_ zJ##sYkFuQJFI<^Lt!WA3{5bc$8zQ3IluLik55Hyg3}wX--u zl*qFQFL+1oyKw-$+!=B__wi_|oEH+0r_JBd{_1-vOY(VAzE>Puq^pch)2tRQ6HA@x z4fiqg6gN9TNkO9;;?hcD$gAd=m0gweTKd6r9@+PZoWg@+T_Pm+rD;N(QqeDdVOD|K zDaHN97??)2QrYRhjU&yYoLgf3IL6#UF zmE;*gpd`@==V*A#s@Z6Fixg4o^!LQ?Ro~iXF^I5o)TicAvWk}eyvf}h*lL#}8D@H= z;ivs-`B{o{conFHY*ak`bCB7vdD))<(^K=~BTOp60Ti`8+!p>^3!6)Qw1?=dg3hs) zT%1WbJ!1gK&t#L977nYa$tY!#x<|_qmi*d2aW_AK+{IcBOtj~G*^I$kxh6MGtnGh+ zeFveNyku*=co?T#BDjZ-i-8lr0aN`3c=te7h$Byx(KPK2E&E78=1XTcn2rVdKyWxE6a;?XD2i_G){oz!lC>F*bbEm@iDG7=LhOU3==tTwJB9Y6?G*2i)Th7p zQP$599^+0PvXC2xi*PUHJz2v1ChM2jg2su={heNc(WA%F4yktsZ@1NsFRm#c{QFf- z5s@7`0!$ZKm#>zJh1&;Z{szbZuLS_iZ)00FQz3IcDvje#kJxSrZolPSyDrhc~V)6jC^m7WTo9# z8W+becPPHHt-^A#-<4NGDSwIV!E{%>?@F+Kc-AqGJ)%udAmZP__k5ZE;govU1KvBE z^U9UY8m_vtfRHEL8bYc=z`$^t^g9t)hqC64EaVGQxD{mfsUb$c1Rq`uakoZx@I|ls zEqQA4&36#hO{0;wzcF`j97OJdWyd*vMtczqYvKt$_l@(lu?x!VjRFmB>Y>wW19aBV z^%ZMmCE_h@`3m01C&ku6AJd7O1kAxWWg3pR@>zF?4GKrNCnxv7RZAa;VulA^5zP^N zc6;!SE**QC&?0T)SPtB;B(uc!(N-X zqISiNC%4q;b?Vh#TQe{x^*a{8^>eS=r6r8tHVBX95)U-9ZGRf>DCgJPr)i|MoV9=t%K0V<+?IhH-Gw=PWzESrMuI{qm@s zf0=*#xn61R(AdYDhajMSJGAk7Q|sdwPoLC4_VhejZ>M9x)o?DI*DeTv>j9#=blxvP ztP!xHhI?bwC}3oMk8pg{VMQuH60srgW+{u5TW>4T*gGpO-N_zk&$Hk0kCmTv?$Z(j z(2_AKZaOnbvpVSzQ>W8&}8sVEiB7D zyP4q^Muajhyq`;FLIkLOQU}uozvz@nsaa|lAC@oX-6k8%vn#{E+kUU)?AE>bj&-cK z1MC9aES=xwC^@=Ci#azdw@Ob0PmS{3EOtxF%@UXr_V?LvD1qXB^D*kh`XIO~nT*lI zwq8>lCmCAB!z5Ng!@1cFhl8j=J`p|H9Fw#lj}(?qI@-{R65{`ni>Fr=f(1{cwJNZyYpypT01Thei|z ze^;Iwy~ds7R$t+!oR~gn!|#8m+&u(nzJhlenyTW0EdW(2q;nuM$Z9&z`3B1GqFG2i zeW{G?24cw*j*ihP0DI*VHzSDheuZ@3>lak&Bd=|tffj}6`(guK+nw1}{^ zTdaS_vjIMfacsbiou!uZPjm_hv)g+TmnRaA+7x$~KQm)Sk`dWpN8x!#p%{$=iSGnr zY!80<9VOs>Loyi;h1LG*x#`rTmk%GhJv1w@$<&KI>b+>-zh@K}Y60?DW*S=ad@DTG zUIvF`xgzVmaG1?!c%; zJVS|Ncx#4Y?ophUC#srWvs0hgAo^#XqZqUI4=4(9YOm3MQU`v8!=N=SIpAtkHVYS? z!74AYNkbAFFH@pF0So9M+3e9o47LMCl39Cm0;L-eZAGA1Yp7Fpf>mze!Ypjo@$}XPRIN)SsXS$0T}|#= z$WnW07U{@!s|oX9J9ffx0@z z8;_tL{wM`BH&Kd?Q8nmQ6^qd5rvuarAysR-)h#A!p^|FM>zqo)&8Gv(?PqBnVig#R zgid>xOTq4HmDw?yn~N2r?f9+Bn}R@14XDC8f3@nAU;O_o>pFm%YQk+0q=qV;gh=mI zniK^hMd`g)0qLQK9u3k0(gIQiLX#%FDf?Wz$=sQ{XU_L+ zlDqfrX1{ZshM=jAI1x${0P3nBN&N-M+=q9a31b&|KM?vl9+e+FFB{8FSWRxC*p;H# z_|8dP{Hbc|=z$#H&b*?@Dt>VKyc9;%q#nYh-0MpH!5?|)vX!TzlceTe3idDJ$d+am z70FPjEKad{t~P~a9<>yUOmX`lg7{3czU5g|ruO75r!?qSp#JTxp?%sGjS-oZ&J5;) zc$dJN;PHL${ou07|c-ADHhdszuWDmPxhC@xk6WGI#q>~1Bl9a=v z(Rd%T;yOgp+~BXXg(oS$&ceV2(~3iW-#`Y# z{8z;CHpQq*7bAPUR+2df)x5L=@Sy_BpoP6;xrp*ap(hq-dADojxRI1Pl3B{5=?3ot zed?2c9y-P^ya#<*7h=ETQ?}GR<*fRqUoc(qkdWPg%?=iPgCMWNcjolBabeEaISnf?06-;uzEQwCMjU~8 z`KqQc`4aN}r_xEkK*u>Y<+q|RPoIkcNy~JFf~w@S2WuJF*?bPCzl=BU(N_1SCLSIQz zb+dfben{DzRMl43;Nzl!kOREQH9TV%>k65s1nPx4G8n#HixakdC`)+DQ9rns3WKza zNR$hGOBxyUc&E65tIt^BX6Ix{U_4=Eze}99@XJAX!X&GMwG8kK?m7^+CAyFJ0w?<kn8Cuu3~VK0Cqp zK)OX7^?b(saBiMii&2<%HqM3_@N0$ZTs;=n#+A``!`5Hwda$k3^gi0~4WE$AA2cS- zQDfsZM2uELnWsAV7YC1K4-?hOD}J7yB_^}ShD#*hQEW9WW1c8)WT%Io=sc?9s=(!; zKTO_~0>7Wul^V!UNgV8#gZK%>QeybRHLvz-uk47x*&#kzGL2j>Y%aL~o5Qzy2t)D} ziF;@Jo8_<9ZtLwTnZDmHlh-pk3Afqcr^oB`wv3n^7Hxfzk_oKVZBAFhUvpwl=w*%8 zCobc*OSDvH0`8~tV3AQ>O2vZS+SBxy|?0YRByy4jW<1 znXs+-pFft20CdeIFu8nKWkW?hp$>Ox&fBC{3J5hX;O4Y~%;-}Y>JAOKx;Bs26cr?)i1e5lL<5(9J!OxFL^kOQQW>@pbH@3ggK!%MTCrs+XaR#! zuBZ>woTb^5ot@{Dw+8cBV;6y8k0u$W#Y#lMeR_kv6rT)KT=kSA&^w2$l=~Q~uG>Ii zpjfVQ4zZ@rRBwcR6gjd6&MNiDh6XCtDLgv!@x{_NRxa}j1DY3{W%XcT1Yw9p7h%7s zPkMu)oz$#{Gm1QTUS9n%gS}KyWSo!s}4%qCh!;1mxap3`?t0dhe#}3xMvVfZ?w%E zrs!<(*^Cu=G<4++bx|||KL+^vOktdJL%YAXiS;b%IH4+4io0M%EO-|4(w`1*@*5mcSxbF1t7D5zVo|!%r!F=pAsVT&!Z(9v+dp`)U+_59q0wja?II61 z2%s>Qf^vavIwkERig5T(9}Abh;HmNvw$b3XIwGy*o*?yRTuT~)u!+7V6;HQAa;72zp(Jsl|__|c7Jl(^n&aG5B z-gnzhKnKTbBP&aI0tj1QrIW$sw~Vw_`(>9m@o7z~yHyu=Rgkx$1S2#8Z|7%HEG*w5 zU~$Q#xaQTW}*h_pH9D}p`ZIp^C9uG|PAqnuHMnrzsi*eM={ z-5yaL;}uG$e8aO67D8Z1>P|ZMnoPn{8;x5x(iRzKb1N)}I^SRZS-dU3KjOYhM=P$G zU2DXq2l&d0K2*5ES{37VzulugPEa`9@8;R6ug@J>^}bkD+*C&b2Eu&mX&*2bomUYR z*k5%(Ow4XWe))VTp*{73ievrY?i2j21Akdcfc}XFAuM#=V8hzfp%n|(lOiua1|v#@ zT(1;sir8gvxI-;oA#sNK68KZSNWg_0jT^M$f;(Kt57NGMKo+)kYIldfm3pq8SHQm2a2xWhc}B;nKSK@e zqF^J7tZE9#&|+%(q%^KK$d7vZtmaX|;E%Qs#hg%L_fTp&S@8*%CkkrShd2YSy*j8r zJJWo+hil|D!P%7fT)gAS{aMnt3hXR5I-FgRktT=tki&56ueAAlAlu(AFvK(? z&Q#d@Jgo`W4h~|g8-$d=4Cfa{@rT~+v0h&JD9`~Is()`e*i5?g88+J{q zi7sK->aXCMo1NJr#n}L;@j1$ilz$b!=!UcOR@5==3X{ z=1&ESQNA}VM460&M|io^4EuBEBv28S;!@W!M(8ObHW`y=DWyX{URahhMmrWJ@UNy6 z@VLz+4#Y19zKO1H6e=H>Kq>KRGWw+$mQ2pgPRvfzg-8>!P~0&SmvLDakBN{UtsyBv zK3hg9JHH!8@X>T?%6)khq{%Rqad(RN`z5~kHLe4F9U(g1)9!dC6BPv#4FlI0)o!uU zOD;#vX&HBE^$03f;e`BFw*oX%gH%WE+)j^rTyD^xdHF#_h~#qZbl0e>q`udXX05CA z5s8nFVEsgv$^<2j)x8Nyn%-(ryvuIp$B00^D&B`r*T8PDnliTXS*(~C<^yP zD<-^AWoBY`w%d!zCcdQ=Yx%r4BB!B~52IeTD6eQY>v|8zxvg+q)_Ksd=J&aJj`0<3 z9Z9)+!_CX!OF!}om+{7h1YuR{rmKql4>ez3HWYurtxAknqg3WxtlA^&yGZtc=K5xb zM7N75ZR7^RmI6*Zqbr=%ayoKp`m5k95F=7+J~`x@f5)C<|Mn#~$;`A}&!dcd$?6)p z*9r4{9%TiXY2~RMFBkiJ6T_1wlMkMVt{e$uX-#?PMv{~9Omb8eF~B50kUtc@ukgT7 zTD2BmO{}e@mM4>-S_OGDJR|PWVr%#QMLLJ1N>?W{!dBH0WhQzk&U1K{W18*cZQ4|XehcT6o$#CThQl2WOwF1j>hSR;>naB+0!Z1@p zgDg8z!G5w7aK3dFWi}O|q0jSoNMH9P8M__4Y~V++Y}>?N(&=wD5RqoN!7MMcwf=o->0%9`=}J0qEJOz`pB80vebVp zX3{0LL_df5^HSZ-9LSe-Cf>Pihk4T#+KM5$r!~r5YbYza$to$&Dnmd2tE7AL6#mw) zQB6AW>goNr?1rVltB#d^w@NKT=$oTV;zomDb~|}dpG`o5H8N4Kn)ojQ&kJPUKYvq= zk4S;bh;PZ!Z3{5;!Xmfh%GV?0z!_D~ctXr2Hnxb_{mklZ1$7&;qFjqLR;LV|9?Tu# z+5UW=gP2x<4< z+AhOK81A93kB0d&lM-7FH`*W(s+;Igqga8M@^ zyd3r3i+aQMC79r~rBOSO&S?W+S|RFfMvX_^5FD;lvVEh&;5&8i*E^VntSw#%)gA+i z8YSoX=kNA+0`Az}N7#Zx)yvvda&9d!2TpZ1|kG9Z!s-kJ6D4`A)4W;+t&GtgK1DRMGD3?@*2}P7p5MO*+A2+F_cB zm`|GA`bKtw>)s8a?8vENKsZqUROHOD<@?WEODw$;<-OOE5?P0LWfwXQjoGu%WeR+0g^}luWRbkr5n^-RL=g5d=k%oj{Nr=|KDCM@~ delta 35825 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
g-vUc>#35|rt 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 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index b21b69e..1eca32e 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,8 +1,10 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip -distributionSha256Sum=bafc141b619ad6350fd975fc903156dd5c151998cc8b058e8c1044ab5f7b031f +distributionSha256Sum=bafd5ce9cfaea0fbccfdc8439a1ac42fbd4cd9c89dc9a988228d8a2639a58e6c +distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip networkTimeout=10000 +retries=0 +retryBackOffMs=500 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 23d15a9..249efbb 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ #!/bin/sh # -# Copyright © 2015-2021 the original authors. +# Copyright © 2015 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -20,7 +20,7 @@ ############################################################################## # -# Gradle start up script for POSIX generated by Gradle. +# gradlew start up script for POSIX generated by Gradle. # # Important for running: # @@ -29,7 +29,7 @@ # bash, then to run this script, type that shell name before the whole # command line, like: # -# ksh Gradle +# ksh gradlew # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: @@ -57,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -114,7 +114,6 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH="\\\"\\\"" # Determine the Java command to use to start the JVM. @@ -172,7 +171,6 @@ fi # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -212,7 +210,6 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" diff --git a/gradlew.bat b/gradlew.bat index db3a6ac..3185a43 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -19,12 +19,39 @@ @if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem -@rem Gradle startup script for Windows +@rem gradlew startup script for Windows @rem @rem ########################################################################## -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +@rem Catch executions from older scripts and ensure they exit cleanly. +@rem This can be removed once we can be reasonably confident that few people +@rem will be migrating directly to this new wrapper. +goto afterSafetyNet +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +goto exitWithErrorLevel +:afterSafetyNet set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @@ -45,13 +72,14 @@ set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. -goto fail +"%COMSPEC%" /c exit 1 +goto exitWithErrorLevel :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -59,36 +87,26 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. -goto fail +"%COMSPEC%" /c exit 1 +goto exitWithErrorLevel :execute @rem Setup the command line -set CLASSPATH= -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +@rem This label must not be changed. We rely on old scripts being able to jump to this point. +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/settings.gradle.kts b/settings.gradle.kts index 2a42f6c..ba063be 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -35,5 +35,8 @@ include(":core-time") include(":core-reminders") include(":core-locale") include(":core-crash") +include(":core-prefs") +include(":core-di") include(":identity") include(":components") +include(":glance")