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:
Jean-Luc Makiola
2026-10-07 09:36:15 +02:00
co-authored by makiolaj
parent 318aeab6bd
commit 8b33635a85
55 changed files with 2517 additions and 80 deletions
+96 -1
View File
@@ -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
View File
@@ -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.
+7 -1
View File
@@ -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
View File
@@ -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"
}
@@ -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 },
)
+39
View File
@@ -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()
+55
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+63
View File
@@ -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>
@@ -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)
}
}
+28 -9
View File
@@ -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" }
Binary file not shown.
+4 -2
View File
@@ -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
Vendored
+4 -7
View File
@@ -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
View File
@@ -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%
+3
View File
@@ -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")