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 <business@jeanlucmakiola.de> Reviewed-on: https://codeberg.org/jlmakiola/floret-kit/pulls/11
This commit is contained in:
+96
-1
@@ -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<T>` 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<Preferences>` 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
|
||||
|
||||
+7
-1
@@ -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 `<module>/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.<area>"`, `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.<area>"`, `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/<area>/`. The root
|
||||
build's `subprojects {}` sets the shared `group`/`version`, so no per-module
|
||||
coordinates are needed.
|
||||
|
||||
@@ -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<T>` + `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)
|
||||
```
|
||||
|
||||
+2
-1
@@ -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"
|
||||
}
|
||||
|
||||
+33
-11
@@ -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
|
||||
|
||||
@@ -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<String?>(null) + supported }
|
||||
@@ -145,7 +145,7 @@ fun LanguagePickerRow(
|
||||
label = { label(it) },
|
||||
onSelect = {
|
||||
current = it
|
||||
AppLanguage.apply(it)
|
||||
AppLanguage.apply(context, it)
|
||||
},
|
||||
onDismiss = { showDialog = false },
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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>): 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>): 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()
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// core-prefs — the family's typed DataStore layer: `Pref<T>` (a key + a default +
|
||||
// its codec), `PrefStore` (typed, distinct-until-changed reads and writes over a
|
||||
// `DataStore<Preferences>`), `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<Preferences>` 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<Preferences>` 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)
|
||||
}
|
||||
@@ -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<ThemeMode> = enumPref("theme_mode", ThemeMode.SYSTEM)
|
||||
val dynamicColor: Pref<Boolean> = booleanPref("dynamic_color", true)
|
||||
}
|
||||
|
||||
/** Both appearance choices as one distinct-until-changed stream. */
|
||||
fun PrefStore.appearance(): Flow<Appearance> = data
|
||||
.map {
|
||||
Appearance(
|
||||
themeMode = AppearancePrefs.themeMode.read(it),
|
||||
dynamicColor = AppearancePrefs.dynamicColor.read(it),
|
||||
)
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
@@ -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 <E : Enum<E>> String?.toEnum(entries: Collection<E>, default: E): E =
|
||||
entries.firstOrNull { it.name == this } ?: default
|
||||
|
||||
/** Stored [Enum.name] -> entry; `null`, unknown and wrong-case fall back to [default]. */
|
||||
inline fun <reified E : Enum<E>> String?.toEnum(default: E): E =
|
||||
toEnum(enumValues<E>().toList(), default)
|
||||
|
||||
/** An enum stored by [Enum.name]; an unreadable value degrades to [default] rather than throwing. */
|
||||
fun <E : Enum<E>> enumPref(name: String, default: E, entries: Collection<E>): Pref<E> {
|
||||
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 <reified E : Enum<E>> enumPref(name: String, default: E): Pref<E> =
|
||||
enumPref(name, default, enumValues<E>().toList())
|
||||
@@ -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<T> 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 <R> map(decode: (T) -> R, encode: (R) -> T): Pref<R> = 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<Boolean> =
|
||||
scalarPref(booleanPreferencesKey(name), name, default)
|
||||
|
||||
fun intPref(name: String, default: Int): Pref<Int> =
|
||||
scalarPref(intPreferencesKey(name), name, default)
|
||||
|
||||
fun longPref(name: String, default: Long): Pref<Long> =
|
||||
scalarPref(longPreferencesKey(name), name, default)
|
||||
|
||||
fun floatPref(name: String, default: Float): Pref<Float> =
|
||||
scalarPref(floatPreferencesKey(name), name, default)
|
||||
|
||||
fun stringPref(name: String, default: String): Pref<String> =
|
||||
scalarPref(stringPreferencesKey(name), name, default)
|
||||
|
||||
fun stringSetPref(name: String, default: Set<String> = emptySet()): Pref<Set<String>> =
|
||||
scalarPref(stringSetPreferencesKey(name), name, default)
|
||||
|
||||
/** Absent reads back as `null`; writing `null` removes the key. */
|
||||
fun nullableStringPref(name: String): Pref<String?> =
|
||||
nullablePref(stringPreferencesKey(name), name)
|
||||
|
||||
/** Absent reads back as `null`; writing `null` removes the key. */
|
||||
fun nullableLongPref(name: String): Pref<Long?> =
|
||||
nullablePref(longPreferencesKey(name), name)
|
||||
|
||||
/** An absent key reads back as [default]; the value is stored under [key] verbatim. */
|
||||
private fun <T : Any> scalarPref(key: Preferences.Key<T>, name: String, default: T): Pref<T> =
|
||||
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 <T : Any> nullablePref(key: Preferences.Key<T>, name: String): Pref<T?> =
|
||||
Pref(
|
||||
name = name,
|
||||
default = null,
|
||||
key = key,
|
||||
read = { it[key] },
|
||||
write = { preferences, value ->
|
||||
if (value == null) preferences.remove(key) else preferences[key] = value
|
||||
},
|
||||
)
|
||||
@@ -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<Preferences>) {
|
||||
|
||||
/** The raw snapshot stream — the escape hatch for composing several keys in one map. */
|
||||
val data: Flow<Preferences> get() = dataStore.data
|
||||
|
||||
/** [pref]'s value, distinct-until-changed, so an unrelated key's write doesn't re-emit. */
|
||||
fun <T> flow(pref: Pref<T>): Flow<T> = dataStore.data.map(pref.read).distinctUntilChanged()
|
||||
|
||||
suspend fun <T> get(pref: Pref<T>): T = pref.read(dataStore.data.first())
|
||||
|
||||
suspend fun <T> set(pref: Pref<T>, value: T) {
|
||||
dataStore.edit { pref.write(it, value) }
|
||||
}
|
||||
|
||||
/** Atomic read-modify-write; returns the stored value after the edit. */
|
||||
suspend fun <T> update(pref: Pref<T>, 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 <T> clear(pref: Pref<T>) {
|
||||
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) }
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
+15
-1
@@ -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.
|
||||
|
||||
|
||||
+2
-1
@@ -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.
|
||||
|
||||
|
||||
+15
-5
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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<Class<*>>
|
||||
|
||||
/** 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<String> = 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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<Class<*>>,
|
||||
rolloverPendingIntent: (Context) -> PendingIntent,
|
||||
) {
|
||||
val appContext = context.applicationContext
|
||||
val alarmManager = appContext.getSystemService<AlarmManager>() ?: 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<Class<*>>): Boolean {
|
||||
val manager = AppWidgetManager.getInstance(context) ?: return false
|
||||
return widgetReceiverClasses.any { manager.getAppWidgetIds(ComponentName(context, it)).isNotEmpty() }
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp" android:height="24dp"
|
||||
android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="@android:color/white"
|
||||
android:pathData="M19,13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp" android:height="24dp"
|
||||
android:viewportWidth="24" android:viewportHeight="24">
|
||||
<!-- Rounded-square frame, matching M3 Checkbox's unchecked shape rather than a
|
||||
circle, so the widget's tap-to-complete affordance reads the same as the
|
||||
app's own task checkbox. -->
|
||||
<path android:fillColor="@android:color/white" android:fillType="evenOdd"
|
||||
android:pathData="M6,3 H18 A3,3 0 0 1 21,6 V18 A3,3 0 0 1 18,21 H6 A3,3 0 0 1 3,18 V6 A3,3 0 0 1 6,3 Z
|
||||
M6.5,5 H17.5 A1.5,1.5 0 0 1 19,6.5 V17.5 A1.5,1.5 0 0 1 17.5,19 H6.5 A1.5,1.5 0 0 1 5,17.5 V6.5 A1.5,1.5 0 0 1 6.5,5 Z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,11 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp" android:height="24dp"
|
||||
android:viewportWidth="24" android:viewportHeight="24">
|
||||
<!-- Two checked rows, unlike floret_ic_widget_check's single mark — reads as
|
||||
"tasks exist here" rather than "this one task is done". -->
|
||||
<path android:fillColor="@android:color/white"
|
||||
android:pathData="M22,7 H13 V9 H22 V7 Z
|
||||
M22,15 H13 V17 H22 V15 Z
|
||||
M5.54,11 L2,7.46 L3.41,6.05 L5.54,8.17 L9.78,3.93 L11.19,5.34 L5.54,11 Z
|
||||
M5.54,19 L2,15.46 L3.41,14.05 L5.54,16.17 L9.78,11.93 L11.19,13.34 L5.54,19 Z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- A single-row group: full radius on every corner. Tinted at use (WidgetGroupedRow). -->
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<solid android:color="@android:color/white" />
|
||||
<corners android:radius="22dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Bottom of a group: small radius at the inner (top) corners, full at the outer (bottom) corners. -->
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<solid android:color="@android:color/white" />
|
||||
<corners
|
||||
android:topLeftRadius="6dp"
|
||||
android:topRightRadius="6dp"
|
||||
android:bottomLeftRadius="22dp"
|
||||
android:bottomRightRadius="22dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Middle of a group: small radius on every corner. -->
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<solid android:color="@android:color/white" />
|
||||
<corners android:radius="6dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Top of a group: full radius at the outer (top) corners, small at the inner (bottom) corners. -->
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<solid android:color="@android:color/white" />
|
||||
<corners
|
||||
android:topLeftRadius="22dp"
|
||||
android:topRightRadius="22dp"
|
||||
android:bottomLeftRadius="6dp"
|
||||
android:bottomRightRadius="6dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,7 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="48dp" android:height="48dp"
|
||||
android:viewportWidth="100" android:viewportHeight="100">
|
||||
<!-- M3 Expressive "cookie" shape: a circle with nine soft scallops. White fill, tinted at the use site. -->
|
||||
<path android:fillColor="@android:color/white"
|
||||
android:pathData="M50.00,0.00 L50.87,0.05 L51.74,0.21 L52.60,0.47 L53.44,0.83 L54.26,1.27 L55.07,1.79 L55.85,2.38 L56.60,3.02 L57.33,3.70 L58.04,4.41 L58.72,5.12 L59.39,5.83 L60.04,6.53 L60.67,7.19 L61.30,7.81 L61.94,8.38 L62.57,8.88 L63.22,9.32 L63.88,9.68 L64.57,9.98 L65.28,10.19 L66.02,10.34 L66.80,10.42 L67.61,10.44 L68.46,10.41 L69.34,10.35 L70.25,10.25 L71.20,10.13 L72.16,10.02 L73.15,9.91 L74.14,9.82 L75.14,9.77 L76.13,9.76 L77.11,9.81 L78.06,9.93 L78.97,10.12 L79.85,10.39 L80.67,10.74 L81.44,11.18 L82.14,11.70 L82.77,12.30 L83.34,12.98 L83.82,13.73 L84.24,14.54 L84.59,15.41 L84.87,16.33 L85.09,17.28 L85.26,18.26 L85.38,19.25 L85.47,20.24 L85.53,21.23 L85.58,22.20 L85.63,23.15 L85.69,24.07 L85.78,24.95 L85.90,25.79 L86.06,26.58 L86.27,27.33 L86.55,28.04 L86.89,28.70 L87.29,29.33 L87.77,29.92 L88.31,30.48 L88.92,31.02 L89.59,31.54 L90.30,32.06 L91.07,32.57 L91.86,33.09 L92.68,33.62 L93.50,34.17 L94.32,34.74 L95.12,35.34 L95.88,35.97 L96.60,36.64 L97.25,37.34 L97.83,38.08 L98.33,38.84 L98.73,39.64 L99.04,40.47 L99.24,41.32 L99.34,42.19 L99.33,43.07 L99.23,43.96 L99.02,44.85 L98.73,45.74 L98.36,46.62 L97.91,47.49 L97.41,48.34 L96.87,49.18 L96.30,50.00 L95.71,50.80 L95.12,51.58 L94.55,52.33 L94.01,53.08 L93.51,53.81 L93.06,54.53 L92.68,55.24 L92.36,55.95 L92.11,56.67 L91.95,57.40 L91.85,58.14 L91.84,58.89 L91.89,59.67 L92.01,60.48 L92.19,61.30 L92.41,62.16 L92.67,63.04 L92.94,63.95 L93.23,64.88 L93.50,65.83 L93.76,66.80 L93.99,67.77 L94.16,68.75 L94.28,69.72 L94.33,70.67 L94.30,71.61 L94.19,72.52 L93.99,73.39 L93.69,74.22 L93.30,75.00 L92.82,75.73 L92.25,76.40 L91.59,77.01 L90.87,77.56 L90.07,78.06 L89.22,78.49 L88.32,78.87 L87.38,79.21 L86.43,79.50 L85.47,79.76 L84.50,79.99 L83.55,80.21 L82.63,80.43 L81.74,80.65 L80.88,80.88 L80.08,81.15 L79.32,81.45 L78.62,81.79 L77.97,82.18 L77.38,82.63 L76.83,83.14 L76.33,83.71 L75.88,84.34 L75.45,85.03 L75.05,85.78 L74.67,86.58 L74.30,87.42 L73.93,88.29 L73.55,89.19 L73.15,90.09 L72.73,91.00 L72.27,91.89 L71.78,92.75 L71.25,93.57 L70.67,94.33 L70.05,95.03 L69.38,95.65 L68.66,96.19 L67.90,96.64 L67.10,96.98 L66.26,97.23 L65.39,97.38 L64.50,97.43 L63.59,97.38 L62.66,97.25 L61.73,97.03 L60.79,96.75 L59.86,96.40 L58.94,96.01 L58.04,95.59 L57.15,95.15 L56.28,94.71 L55.44,94.28 L54.61,93.88 L53.81,93.51 L53.02,93.19 L52.25,92.94 L51.49,92.75 L50.74,92.63 L50.00,92.59 L49.26,92.63 L48.51,92.75 L47.75,92.94 L46.98,93.19 L46.19,93.51 L45.39,93.88 L44.56,94.28 L43.72,94.71 L42.85,95.15 L41.96,95.59 L41.06,96.01 L40.14,96.40 L39.21,96.75 L38.27,97.03 L37.34,97.25 L36.41,97.38 L35.50,97.43 L34.61,97.38 L33.74,97.23 L32.90,96.98 L32.10,96.64 L31.34,96.19 L30.62,95.65 L29.95,95.03 L29.33,94.33 L28.75,93.57 L28.22,92.75 L27.73,91.89 L27.27,91.00 L26.85,90.09 L26.45,89.19 L26.07,88.29 L25.70,87.42 L25.33,86.58 L24.95,85.78 L24.55,85.03 L24.12,84.34 L23.67,83.71 L23.17,83.14 L22.62,82.63 L22.03,82.18 L21.38,81.79 L20.68,81.45 L19.92,81.15 L19.12,80.88 L18.26,80.65 L17.37,80.43 L16.45,80.21 L15.50,79.99 L14.53,79.76 L13.57,79.50 L12.62,79.21 L11.68,78.87 L10.78,78.49 L9.93,78.06 L9.13,77.56 L8.41,77.01 L7.75,76.40 L7.18,75.73 L6.70,75.00 L6.31,74.22 L6.01,73.39 L5.81,72.52 L5.70,71.61 L5.67,70.67 L5.72,69.72 L5.84,68.75 L6.01,67.77 L6.24,66.80 L6.50,65.83 L6.77,64.88 L7.06,63.95 L7.33,63.04 L7.59,62.16 L7.81,61.30 L7.99,60.48 L8.11,59.67 L8.16,58.89 L8.15,58.14 L8.05,57.40 L7.89,56.67 L7.64,55.95 L7.32,55.24 L6.94,54.53 L6.49,53.81 L5.99,53.08 L5.45,52.33 L4.88,51.58 L4.29,50.80 L3.70,50.00 L3.13,49.18 L2.59,48.34 L2.09,47.49 L1.64,46.62 L1.27,45.74 L0.98,44.85 L0.77,43.96 L0.67,43.07 L0.66,42.19 L0.76,41.32 L0.96,40.47 L1.27,39.64 L1.67,38.84 L2.17,38.08 L2.75,37.34 L3.40,36.64 L4.12,35.97 L4.88,35.34 L5.68,34.74 L6.50,34.17 L7.32,33.62 L8.14,33.09 L8.93,32.57 L9.70,32.06 L10.41,31.54 L11.08,31.02 L11.69,30.48 L12.23,29.92 L12.71,29.33 L13.11,28.70 L13.45,28.04 L13.73,27.33 L13.94,26.58 L14.10,25.79 L14.22,24.95 L14.31,24.07 L14.37,23.15 L14.42,22.20 L14.47,21.23 L14.53,20.24 L14.62,19.25 L14.74,18.26 L14.91,17.28 L15.13,16.33 L15.41,15.41 L15.76,14.54 L16.18,13.73 L16.66,12.98 L17.23,12.30 L17.86,11.70 L18.56,11.18 L19.33,10.74 L20.15,10.39 L21.03,10.12 L21.94,9.93 L22.89,9.81 L23.87,9.76 L24.86,9.77 L25.86,9.82 L26.85,9.91 L27.84,10.02 L28.80,10.13 L29.75,10.25 L30.66,10.35 L31.54,10.41 L32.39,10.44 L33.20,10.42 L33.98,10.34 L34.72,10.19 L35.43,9.98 L36.12,9.68 L36.78,9.32 L37.43,8.88 L38.06,8.38 L38.70,7.81 L39.33,7.19 L39.96,6.53 L40.61,5.83 L41.28,5.12 L41.96,4.41 L42.67,3.70 L43.40,3.02 L44.15,2.38 L44.93,1.79 L45.74,1.27 L46.56,0.83 L47.40,0.47 L48.26,0.21 L49.13,0.05 Z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,7 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="48dp" android:height="48dp"
|
||||
android:viewportWidth="100" android:viewportHeight="100">
|
||||
<!-- M3 Expressive "sunny" shape: a softly pointed eight-ray burst. White fill, tinted at the use site. -->
|
||||
<path android:fillColor="@android:color/white"
|
||||
android:pathData="M50.00,0.00 L50.87,0.03 L51.74,0.12 L52.61,0.27 L53.46,0.48 L54.31,0.75 L55.14,1.06 L55.97,1.41 L56.77,1.81 L57.56,2.24 L58.34,2.70 L59.10,3.17 L59.85,3.66 L60.58,4.16 L61.30,4.66 L62.02,5.15 L62.72,5.63 L63.42,6.10 L64.12,6.54 L64.82,6.96 L65.52,7.36 L66.23,7.72 L66.95,8.05 L67.68,8.35 L68.42,8.63 L69.18,8.87 L69.95,9.09 L70.74,9.29 L71.55,9.47 L72.38,9.63 L73.21,9.79 L74.07,9.95 L74.93,10.11 L75.80,10.27 L76.67,10.45 L77.55,10.65 L78.42,10.88 L79.29,11.14 L80.14,11.43 L80.97,11.75 L81.78,12.12 L82.57,12.54 L83.32,13.00 L84.04,13.50 L84.72,14.05 L85.36,14.64 L85.95,15.28 L86.50,15.96 L87.00,16.68 L87.46,17.43 L87.88,18.22 L88.25,19.03 L88.57,19.86 L88.86,20.71 L89.12,21.58 L89.35,22.45 L89.55,23.33 L89.73,24.20 L89.89,25.07 L90.05,25.93 L90.21,26.79 L90.37,27.62 L90.53,28.45 L90.71,29.26 L90.91,30.05 L91.13,30.82 L91.37,31.58 L91.65,32.32 L91.95,33.05 L92.28,33.77 L92.64,34.48 L93.04,35.18 L93.46,35.88 L93.90,36.58 L94.37,37.28 L94.85,37.98 L95.34,38.70 L95.84,39.42 L96.34,40.15 L96.83,40.90 L97.30,41.66 L97.76,42.44 L98.19,43.23 L98.59,44.03 L98.94,44.86 L99.25,45.69 L99.52,46.54 L99.73,47.39 L99.88,48.26 L99.97,49.13 L100.00,50.00 L99.97,50.87 L99.88,51.74 L99.73,52.61 L99.52,53.46 L99.25,54.31 L98.94,55.14 L98.59,55.97 L98.19,56.77 L97.76,57.56 L97.30,58.34 L96.83,59.10 L96.34,59.85 L95.84,60.58 L95.34,61.30 L94.85,62.02 L94.37,62.72 L93.90,63.42 L93.46,64.12 L93.04,64.82 L92.64,65.52 L92.28,66.23 L91.95,66.95 L91.65,67.68 L91.37,68.42 L91.13,69.18 L90.91,69.95 L90.71,70.74 L90.53,71.55 L90.37,72.38 L90.21,73.21 L90.05,74.07 L89.89,74.93 L89.73,75.80 L89.55,76.67 L89.35,77.55 L89.12,78.42 L88.86,79.29 L88.57,80.14 L88.25,80.97 L87.88,81.78 L87.46,82.57 L87.00,83.32 L86.50,84.04 L85.95,84.72 L85.36,85.36 L84.72,85.95 L84.04,86.50 L83.32,87.00 L82.57,87.46 L81.78,87.88 L80.97,88.25 L80.14,88.57 L79.29,88.86 L78.42,89.12 L77.55,89.35 L76.67,89.55 L75.80,89.73 L74.93,89.89 L74.07,90.05 L73.21,90.21 L72.38,90.37 L71.55,90.53 L70.74,90.71 L69.95,90.91 L69.18,91.13 L68.42,91.37 L67.68,91.65 L66.95,91.95 L66.23,92.28 L65.52,92.64 L64.82,93.04 L64.12,93.46 L63.42,93.90 L62.72,94.37 L62.02,94.85 L61.30,95.34 L60.58,95.84 L59.85,96.34 L59.10,96.83 L58.34,97.30 L57.56,97.76 L56.77,98.19 L55.97,98.59 L55.14,98.94 L54.31,99.25 L53.46,99.52 L52.61,99.73 L51.74,99.88 L50.87,99.97 L50.00,100.00 L49.13,99.97 L48.26,99.88 L47.39,99.73 L46.54,99.52 L45.69,99.25 L44.86,98.94 L44.03,98.59 L43.23,98.19 L42.44,97.76 L41.66,97.30 L40.90,96.83 L40.15,96.34 L39.42,95.84 L38.70,95.34 L37.98,94.85 L37.28,94.37 L36.58,93.90 L35.88,93.46 L35.18,93.04 L34.48,92.64 L33.77,92.28 L33.05,91.95 L32.32,91.65 L31.58,91.37 L30.82,91.13 L30.05,90.91 L29.26,90.71 L28.45,90.53 L27.62,90.37 L26.79,90.21 L25.93,90.05 L25.07,89.89 L24.20,89.73 L23.33,89.55 L22.45,89.35 L21.58,89.12 L20.71,88.86 L19.86,88.57 L19.03,88.25 L18.22,87.88 L17.43,87.46 L16.68,87.00 L15.96,86.50 L15.28,85.95 L14.64,85.36 L14.05,84.72 L13.50,84.04 L13.00,83.32 L12.54,82.57 L12.12,81.78 L11.75,80.97 L11.43,80.14 L11.14,79.29 L10.88,78.42 L10.65,77.55 L10.45,76.67 L10.27,75.80 L10.11,74.93 L9.95,74.07 L9.79,73.21 L9.63,72.38 L9.47,71.55 L9.29,70.74 L9.09,69.95 L8.87,69.18 L8.63,68.42 L8.35,67.68 L8.05,66.95 L7.72,66.23 L7.36,65.52 L6.96,64.82 L6.54,64.12 L6.10,63.42 L5.63,62.72 L5.15,62.02 L4.66,61.30 L4.16,60.58 L3.66,59.85 L3.17,59.10 L2.70,58.34 L2.24,57.56 L1.81,56.77 L1.41,55.97 L1.06,55.14 L0.75,54.31 L0.48,53.46 L0.27,52.61 L0.12,51.74 L0.03,50.87 L0.00,50.00 L0.03,49.13 L0.12,48.26 L0.27,47.39 L0.48,46.54 L0.75,45.69 L1.06,44.86 L1.41,44.03 L1.81,43.23 L2.24,42.44 L2.70,41.66 L3.17,40.90 L3.66,40.15 L4.16,39.42 L4.66,38.70 L5.15,37.98 L5.63,37.28 L6.10,36.58 L6.54,35.88 L6.96,35.18 L7.36,34.48 L7.72,33.77 L8.05,33.05 L8.35,32.32 L8.63,31.58 L8.87,30.82 L9.09,30.05 L9.29,29.26 L9.47,28.45 L9.63,27.62 L9.79,26.79 L9.95,25.93 L10.11,25.07 L10.27,24.20 L10.45,23.33 L10.65,22.45 L10.88,21.58 L11.14,20.71 L11.43,19.86 L11.75,19.03 L12.12,18.22 L12.54,17.43 L13.00,16.68 L13.50,15.96 L14.05,15.28 L14.64,14.64 L15.28,14.05 L15.96,13.50 L16.68,13.00 L17.43,12.54 L18.22,12.12 L19.03,11.75 L19.86,11.43 L20.71,11.14 L21.58,10.88 L22.45,10.65 L23.33,10.45 L24.20,10.27 L25.07,10.11 L25.93,9.95 L26.79,9.79 L27.62,9.63 L28.45,9.47 L29.26,9.29 L30.05,9.09 L30.82,8.87 L31.58,8.63 L32.32,8.35 L33.05,8.05 L33.77,7.72 L34.48,7.36 L35.18,6.96 L35.88,6.54 L36.58,6.10 L37.28,5.63 L37.98,5.15 L38.70,4.66 L39.42,4.16 L40.15,3.66 L40.90,3.17 L41.66,2.70 L42.44,2.24 L43.23,1.81 L44.03,1.41 L44.86,1.06 L45.69,0.75 L46.54,0.48 L47.39,0.27 L48.26,0.12 L49.13,0.03 Z"/>
|
||||
</vector>
|
||||
+51
@@ -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<String> = emptySet()) : FloretWidgetUpdateReceiver() {
|
||||
override val rolloverAction = "app.widget.ROLLOVER"
|
||||
override val widgetReceiverClasses: List<Class<*>> = 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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
Vendored
BIN
Binary file not shown.
+4
-2
@@ -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
|
||||
|
||||
@@ -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" \
|
||||
"$@"
|
||||
|
||||
|
||||
Vendored
+52
-34
@@ -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%
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user