diff --git a/CHANGELOG.md b/CHANGELOG.md index 025fe57..8894c4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,105 @@ All notable changes to floret-kit are documented here. The format follows floret-kit is consumed **from source** — each app embeds it as a git submodule and builds it through a Gradle composite build, pinning a specific commit. There are no binary releases; "version" tracks the shared `version` in the root build -(currently `0.4.0`). Entries are grouped by module. +(currently `0.7.0`). Entries are grouped by module. ## [Unreleased] +### Fixed + +- **`core-locale`** — picking a language did nothing on Android 13+. AppCompat's + `setApplicationLocales` only reaches the platform through a live + `AppCompatActivity`, and every app uses `ComponentActivity`. `AppLanguage` + now calls `LocaleManager` directly there: `apply(context, tag)` and + `currentTag(context[, supported])`. The context-free versions are deprecated. + `LanguagePickerRow` uses the new ones; apps that call `AppLanguage` themselves + (Agendula's settings row) need to pass a context. + +### Changed + +- **Build** — dependencies to the newest stable releases: AGP 9.4.1, Kotlin + 2.4.20, Gradle 9.8.0, Compose BOM 2026.09.00, core-ktx 1.19.1, appcompat + 1.8.0, JUnit 6.1.3; material3 to `1.5.0-alpha29` (still no stable 1.5 with the + Expressive APIs). AGP must match across a composite build, so a consuming app + re-pinning to this needs AGP 9.4.1 itself. + +### Added + +- **`glance`** (new module) — the family's shared home-screen-widget plumbing + and M3 Expressive widget vocabulary, built on Jetpack Glance: + `FloretGlanceTheme` (dynamic colour on API 31+, app-supplied fallback + schemes otherwise, a per-widget `WidgetThemeMode` override), `WidgetSurface` + (solid/tinted/transparent root backgrounds with the system corner radius), + `WidgetHeader` + `WidgetActionButton` (a 48dp tonal/filled pill, extendable + with a label), `WidgetGroupedRow` (the Glance counterpart of `components`' + `GroupedSurface`, built from four position-shaped drawables since Glance's + `cornerRadius` has no per-corner overload), `WidgetMessage`, and the + `WidgetRollover` + `FloretWidgetUpdateReceiver` midnight-rollover plumbing. + Converged from Agendula's widget package, written for the M3 Expressive + widget pass (calendula#71); Calendula adopts it when its own widget overhaul + lands. + +## [0.7.0] - 2026-09-12 + +### Added + +- **`components`** — `CollapsingScaffold` takes `floatingActionButton` and + `floatingActionButtonPosition`, forwarded to the underlying `Scaffold`. Both + default to the bare scaffold, so every existing call site in every app is + unchanged, and they sit after `snackbarHost` so no positional call shifts. + The driver is Clockula's alarms tab: three of its four tabs need an "add" + affordance on a scaffold the kit owns, and the alternative — a `Box` wrapping + the scaffold with a bottom-end FAB — re-derives the window-inset and + position maths `Scaffold` already does, once per tab and again in every + sibling app. + +## [0.6.0] - 2026-09-11 + +### Changed + +- **`components`** — `CollapsingScaffold`'s `onBack` is now nullable and + defaults to null. A null `onBack` draws no navigation icon and wires no + predictive-back handler; every existing call site, which passes a lambda, + is unchanged. The driver is Clockula's four tabs: a top-level destination + wants exactly this scaffold — the large title that shrinks into the bar over + a scrolling column — and nothing behind it to go back to, and a back arrow + that pops nothing is worse than no arrow at all. Cheaper and truer than each + app hand-rolling a second, arrow-less scaffold beside this one. + +## [0.5.0] - 2026-09-11 + +### Added + +- **`core-prefs`** — the family's typed DataStore layer, extracted now that + Clockula is the third app that would have written it again. `Pref` is one + preference as data — its on-disk key name, its default and its codec — built + by `booleanPref`/`intPref`/`longPref`/`floatPref`/`stringPref`/`stringSetPref`, + the nullable pair (`nullableStringPref`/`nullableLongPref`, where writing + `null` *removes* the key rather than storing a sentinel), `enumPref`, and + `map()` for a view of the same slot as another type. `PrefStore` wraps a + `DataStore` with `get`/`set`/`update`/`clear`/`clearAll`, a raw + `edit` escape hatch, and `flow(pref)` — which is `distinctUntilChanged`, so an + unrelated key's write no longer re-emits every screen's setting, the bug both + siblings' hand-rolled `data.map { … }` carried. `String?.toEnum(default)` is + the read path for every enum-valued preference: unknown, empty and wrong-case + values degrade to the default instead of throwing. Also the shared appearance + slice — `ThemeMode` (with `isDark(systemDark)`, the `when` block that was + duplicated in both siblings' `MainActivity`), `Appearance` and + `AppearancePrefs`. The stored key names are byte-identical to what Calendula + and Agendula already write (`theme_mode`, `dynamic_color`), so adopting the + module is a code change with **no data migration**. The store's *name* and its + construction stay with the app. Consumed by Clockula. + +- **`core-di`** — the family's shared DI vocabulary: the `@IoDispatcher`, + `@DefaultDispatcher` and `@ApplicationScope` qualifiers and the + `CoroutinesModule` that provides them (the application scope on a + `SupervisorJob`, so one failing collector can't take its siblings down). Every + app in the family had re-declared the same qualifier and the same provider. + The kit's only module that knows about Dagger, and its only one carrying an + annotation processor — a `@Module @InstallIn(...)` reaches a consuming app's + component only if the library itself is run through `hilt-compiler`. Consumed + by Clockula. + ## [0.4.0] - 2026-09-24 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 041890b..684959a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,6 +36,8 @@ practical how. ```sh ./gradlew :core-time:test # JVM module (JUnit 5 + Truth) ./gradlew :core-crash:testDebugUnitTest +./gradlew :core-prefs:testDebugUnitTest +./gradlew :core-di:test ./gradlew :identity:assembleDebug ./gradlew build # everything ``` @@ -50,7 +52,11 @@ source. [`settings.gradle.kts`](settings.gradle.kts). 2. Add a `/build.gradle.kts`: - **JVM:** `plugins { alias(libs.plugins.kotlin.jvm) }`, a `kotlin { compilerOptions { jvmTarget = JVM_17 } }` block, JUnit 5 test deps. - - **Android:** `plugins { alias(libs.plugins.android.library); alias(libs.plugins.kotlin.compose) }` (no `kotlin.android` — AGP 9.x is built-in), `namespace = "de.jeanlucmakiola.floret."`, `compileSdk 37` / `minSdk 29`, `jvmTarget = JVM_17`. + - **Android:** `plugins { alias(libs.plugins.android.library); alias(libs.plugins.kotlin.compose) }` (no `kotlin.android` — AGP 9.x is built-in), `namespace = "de.jeanlucmakiola.floret."`, `compileSdk 37` / `minSdk 29`, `jvmTarget = JVM_17`. An Android module with no + composables in it (e.g. `core-prefs`) applies only `android.library`. + - **Needs an annotation processor?** Add `alias(libs.plugins.ksp)` and declare + the processor with `ksp(...)` — see `core-di`, whose Hilt `@Module` has to be + processed in the library for a consuming app's component to see it. 3. Put sources under `src/main/kotlin/de/jeanlucmakiola/floret//`. The root build's `subprojects {}` sets the shared `group`/`version`, so no per-module coordinates are needed. diff --git a/README.md b/README.md index 1e18014..821e7ef 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,8 @@ The shared **Material 3 Expressive design system + plumbing** for the Floret family of Android apps ([Calendula](https://gitea.jeanlucmakiola.de/makiolaj/calendula), -[Agendula](https://gitea.jeanlucmakiola.de/makiolaj/agendula), and future siblings). +[Agendula](https://gitea.jeanlucmakiola.de/makiolaj/agendula), +[Clockula](https://gitea.jeanlucmakiola.de/makiolaj/clockula), and future siblings). "Floret" is the family's design language: a Calendula flower head is a cluster of many small *florets*, and each app is one of them. This repo is the bloom they @@ -37,9 +38,12 @@ See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the full consumption mode | `core-time` | JVM | Date/time helpers: `DayWindow` local-day windows, locale/zone-aware `Instant` formatting, `TimeBridge` (millis ↔ `Instant`). No Android, no deps. | | `core-reminders` | JVM | Reminder-lead plumbing: the lead-time unit model (`ReminderUnit`, `decomposeReminderMinutes`), the per-target override model (`ReminderOverride` + `reminderLeadFor` / `applyReminderOverride`) and the stored-format codec (`ReminderOverrideCodec`, separators configurable per app). Each app layers its own DataStore + string labels on top. | | `core-locale` | Android | Everything "what language are we in". Per-app language plumbing (`AppLanguage`): read the shipped languages from the app's `res/xml/locales_config.xml`, get/set the applied language via `AppCompatDelegate`, render each language's autonym. Plus `currentLocale()` (observes the applied language from Compose) and `localizedDateFormatter(locale, skeleton)` (lays a field skeleton out in the locale's own order). The app passes its own `locales_config`. | +| `core-prefs` | Android | The typed DataStore layer: `Pref` + `PrefStore` (typed read/write, distinct-until-changed flows), `toEnum()` for values that degrade instead of throwing, and the family's shared appearance prefs (`ThemeMode`, `dynamicColor`). The store's name and construction stay with the app. | +| `core-di` | JVM | The shared DI vocabulary: `@IoDispatcher`, `@DefaultDispatcher`, `@ApplicationScope` and the Hilt module that provides them. | | `core-crash` | Android | Privacy-respecting on-device crash capture + report dialog + issue-tracker hand-off. Parameterized per app via `CrashConfig`. | | `identity` | Android | The M3 Expressive theme factory `FloretExpressiveTheme(…)`, `rememberNavSlideSpec()`, the content transitions (`expandEnter`/`collapseExit`/`itemEnter`/…) and the `predictiveBack` peek. Each app supplies its own seed/palette. | | `components` | Android | Shared Compose vocabulary + recipes: `GroupedSurface`/`GroupedRow`, `InlineTextField`, `OptionCard`, `CollapsingScaffold`, `OptionPicker`, `ReorderableColumn`, `DebugRibbon`, `pastelize()`, `DialogControls`, `OnboardingScaffold`, `OptionalFormSection`, `AboutCard`, `LanguagePickerRow`, `SnackChip`. | +| `glance` | Android | Home-screen-widget plumbing on Jetpack Glance: `FloretGlanceTheme`, the rounded `WidgetSurface` (solid/tinted/transparent backgrounds), `WidgetHeader`/`WidgetActionButton`, the position-aware `WidgetGroupedRow`, `WidgetMessage`, `WidgetRollover` (the midnight-rollover alarm) and `FloretWidgetUpdateReceiver`. App supplies its own colour schemes, data and receiver glue. | _The principle: the **mechanics** are shared, the **look** stays per-app. See [`docs/ROADMAP.md`](docs/ROADMAP.md) for what's extracted, deferred, and @@ -57,5 +61,7 @@ See [`docs/`](docs/) — start with [`docs/README.md`](docs/README.md) ```sh ./gradlew :core-time:test # JVM module tests ./gradlew :core-crash:testDebugUnitTest +./gradlew :core-prefs:testDebugUnitTest +./gradlew :core-di:test ./gradlew build # everything (Android modules need an SDK; see above) ``` diff --git a/build.gradle.kts b/build.gradle.kts index 637e3db..908c79f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,9 +6,10 @@ plugins { alias(libs.plugins.android.library) apply false alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.kotlin.jvm) apply false + alias(libs.plugins.ksp) apply false } subprojects { group = "de.jeanlucmakiola.floret" - version = "0.4.0" + version = "0.7.0" } diff --git a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/CollapsingScaffold.kt b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/CollapsingScaffold.kt index 345f16f..ecd66ea 100644 --- a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/CollapsingScaffold.kt +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/CollapsingScaffold.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FabPosition import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LargeTopAppBar @@ -44,10 +45,17 @@ import de.jeanlucmakiola.floret.identity.predictiveBack * * Optional extras, all defaulting to the bare scaffold so existing callers are * unchanged: [actions] adds trailing app-bar items; [snackbarHost] hosts - * transient messages; [predictiveBack] (off by default) wires the gesture - * preview to [onBack] for full-screen surfaces that want it, with [visible] - * passed through to [Modifier.predictiveBack] for a surface that is shown and - * hidden in place. + * transient messages; [floatingActionButton] (with + * [floatingActionButtonPosition]) places a FAB the underlying [Scaffold] + * positions and insets — for a list destination whose primary action is "add"; + * [predictiveBack] (off by default) wires the gesture preview to [onBack] for + * full-screen surfaces that want it, with [visible] passed through to + * [Modifier.predictiveBack] for a surface that is shown and hidden in place. + * + * [onBack] is optional. Pass null for a **top-level destination** — a tab in an + * app's navigation bar, say — which has nothing to go back to: no navigation + * icon is drawn, and [predictiveBack] wires nothing, so the gesture falls + * through to whoever owns the back stack above. * * Set [scrollable] to false when [content] brings its own scrolling container. * The default column scrolls as a whole, which composes every row up front — @@ -63,7 +71,7 @@ import de.jeanlucmakiola.floret.identity.predictiveBack @Composable fun CollapsingScaffold( title: String, - onBack: () -> Unit, + onBack: (() -> Unit)? = null, modifier: Modifier = Modifier, largeTopBar: Boolean = true, predictiveBack: Boolean = false, @@ -71,6 +79,8 @@ fun CollapsingScaffold( scrollable: Boolean = true, actions: @Composable RowScope.() -> Unit = {}, snackbarHost: @Composable () -> Unit = {}, + floatingActionButton: @Composable () -> Unit = {}, + floatingActionButtonPosition: FabPosition = FabPosition.End, content: @Composable ColumnScope.() -> Unit, ) { val scrollBehavior = if (largeTopBar) { @@ -80,17 +90,27 @@ fun CollapsingScaffold( } Scaffold( modifier = modifier - .then(if (predictiveBack) Modifier.predictiveBack(onBack = onBack, visible = visible) else Modifier) + .then( + if (predictiveBack && onBack != null) { + Modifier.predictiveBack(onBack = onBack, visible = visible) + } else { + Modifier + }, + ) .fillMaxSize() .background(MaterialTheme.colorScheme.surface) .nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { val navigationIcon = @Composable { - IconButton(onClick = onBack) { - Icon( - Icons.AutoMirrored.Rounded.ArrowBack, - contentDescription = stringResource(R.string.back), - ) + // A top-level destination has nothing to go back to, so a null + // [onBack] draws no arrow at all rather than an arrow that lies. + if (onBack != null) { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(R.string.back), + ) + } } } if (largeTopBar) { @@ -116,6 +136,8 @@ fun CollapsingScaffold( } }, snackbarHost = snackbarHost, + floatingActionButton = floatingActionButton, + floatingActionButtonPosition = floatingActionButtonPosition, ) { innerPadding -> Column( modifier = Modifier diff --git a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/SettingsRecipes.kt b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/SettingsRecipes.kt index c7055ee..3f82d9a 100644 --- a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/SettingsRecipes.kt +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/SettingsRecipes.kt @@ -123,7 +123,7 @@ fun LanguagePickerRow( ) { val context = LocalContext.current val supported = remember { AppLanguage.supportedTags(context, localesConfig) } - var current by remember { mutableStateOf(AppLanguage.currentTag(supported)) } + var current by remember { mutableStateOf(AppLanguage.currentTag(context, supported)) } var showDialog by remember { mutableStateOf(false) } // null = follow the system; the rest are BCP-47 tags from locales_config.xml. val options = remember(supported) { listOf(null) + supported } @@ -145,7 +145,7 @@ fun LanguagePickerRow( label = { label(it) }, onSelect = { current = it - AppLanguage.apply(it) + AppLanguage.apply(context, it) }, onDismiss = { showDialog = false }, ) diff --git a/core-di/build.gradle.kts b/core-di/build.gradle.kts new file mode 100644 index 0000000..dc57a34 --- /dev/null +++ b/core-di/build.gradle.kts @@ -0,0 +1,39 @@ +// core-di — the family's shared DI vocabulary: the `@IoDispatcher`, +// `@DefaultDispatcher` and `@ApplicationScope` qualifiers plus the Hilt module +// that provides them. A plain JVM library (nothing here touches Android), but +// the one kit module that carries an annotation processor: a `@Module +// @InstallIn(...)` only reaches a consuming app's component if the *library* +// itself is run through `hilt-compiler`, which generates the `@AggregatedDeps` +// metadata the app's processor reads off the classpath. +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.ksp) +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +dependencies { + // `CoroutineDispatcher` / `CoroutineScope` are returned from the module. + api(libs.kotlinx.coroutines.core) + implementation(libs.hilt.core) + ksp(libs.hilt.compiler) + + testImplementation(libs.junit.jupiter.api) + testRuntimeOnly(libs.junit.jupiter.engine) + testRuntimeOnly(libs.junit.platform.launcher) + testImplementation(libs.truth) + testImplementation(libs.kotlinx.coroutines.test) +} + +tasks.test { + useJUnitPlatform() +} diff --git a/core-di/src/main/kotlin/de/jeanlucmakiola/floret/di/CoroutinesModule.kt b/core-di/src/main/kotlin/de/jeanlucmakiola/floret/di/CoroutinesModule.kt new file mode 100644 index 0000000..a3173d3 --- /dev/null +++ b/core-di/src/main/kotlin/de/jeanlucmakiola/floret/di/CoroutinesModule.kt @@ -0,0 +1,32 @@ +package de.jeanlucmakiola.floret.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import javax.inject.Singleton + +/** Provides the family's dispatchers and the application-lifetime scope. */ +@Module +@InstallIn(SingletonComponent::class) +object CoroutinesModule { + + @Provides + @IoDispatcher + fun ioDispatcher(): CoroutineDispatcher = Dispatchers.IO + + @Provides + @DefaultDispatcher + fun defaultDispatcher(): CoroutineDispatcher = Dispatchers.Default + + /** SupervisorJob, so one failing collector can't take the others down with it. */ + @Provides + @Singleton + @ApplicationScope + fun applicationScope(@DefaultDispatcher dispatcher: CoroutineDispatcher): CoroutineScope = + CoroutineScope(SupervisorJob() + dispatcher) +} diff --git a/core-di/src/main/kotlin/de/jeanlucmakiola/floret/di/Qualifiers.kt b/core-di/src/main/kotlin/de/jeanlucmakiola/floret/di/Qualifiers.kt new file mode 100644 index 0000000..d53a69e --- /dev/null +++ b/core-di/src/main/kotlin/de/jeanlucmakiola/floret/di/Qualifiers.kt @@ -0,0 +1,21 @@ +package de.jeanlucmakiola.floret.di + +import javax.inject.Qualifier + +/** The IO [kotlinx.coroutines.CoroutineDispatcher] — disk, network, content providers. */ +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class IoDispatcher + +/** The CPU-bound default [kotlinx.coroutines.CoroutineDispatcher]. */ +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class DefaultDispatcher + +/** + * The process-lifetime [kotlinx.coroutines.CoroutineScope] — for work that outlives + * any screen. Never cancelled, so nothing unbounded may be launched in it. + */ +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class ApplicationScope diff --git a/core-di/src/test/kotlin/de/jeanlucmakiola/floret/di/CoroutinesModuleTest.kt b/core-di/src/test/kotlin/de/jeanlucmakiola/floret/di/CoroutinesModuleTest.kt new file mode 100644 index 0000000..27b1e48 --- /dev/null +++ b/core-di/src/test/kotlin/de/jeanlucmakiola/floret/di/CoroutinesModuleTest.kt @@ -0,0 +1,61 @@ +package de.jeanlucmakiola.floret.di + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import kotlin.coroutines.ContinuationInterceptor + +/** What the Hilt module hands out, checked by calling the provider functions directly. */ +class CoroutinesModuleTest { + + @Test + fun `ioDispatcher provides the IO dispatcher`() { + val dispatcher = CoroutinesModule.ioDispatcher() + + assertThat(dispatcher).isSameInstanceAs(Dispatchers.IO) + } + + @Test + fun `defaultDispatcher provides the Default dispatcher`() { + val dispatcher = CoroutinesModule.defaultDispatcher() + + assertThat(dispatcher).isSameInstanceAs(Dispatchers.Default) + } + + @Test + fun `the application scope runs on the dispatcher it was given`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + + val scope = CoroutinesModule.applicationScope(dispatcher) + + assertThat(scope.coroutineContext[ContinuationInterceptor]).isSameInstanceAs(dispatcher) + } + + @Test + fun `a failing child does not take down its sibling`() = runTest { + val scope = CoroutinesModule.applicationScope(UnconfinedTestDispatcher(testScheduler)) + val swallow = CoroutineExceptionHandler { _, _ -> } + var siblingCompleted = false + + scope.launch(swallow) { error("boom") } + scope.launch { siblingCompleted = true } + + assertThat(siblingCompleted && scope.isActive).isTrue() + } + + @Test + fun `the application scope is handed out live`() { + val scope = CoroutinesModule.applicationScope(Dispatchers.Unconfined) + + val job = scope.coroutineContext[Job] + + assertThat(job?.isActive).isTrue() + } +} diff --git a/core-di/src/test/kotlin/de/jeanlucmakiola/floret/di/QualifiersTest.kt b/core-di/src/test/kotlin/de/jeanlucmakiola/floret/di/QualifiersTest.kt new file mode 100644 index 0000000..eb440a7 --- /dev/null +++ b/core-di/src/test/kotlin/de/jeanlucmakiola/floret/di/QualifiersTest.kt @@ -0,0 +1,68 @@ +package de.jeanlucmakiola.floret.di + +import com.google.common.truth.Truth.assertThat +import dagger.Module +import org.junit.jupiter.api.Test +import java.lang.annotation.Retention +import java.lang.annotation.RetentionPolicy +import javax.inject.Qualifier + +/** + * The DI vocabulary itself: an annotation only works as a Dagger qualifier if it + * carries `@Qualifier` and survives into the class file. + */ +class QualifiersTest { + + private val qualifiers = listOf( + IoDispatcher::class.java, + DefaultDispatcher::class.java, + ApplicationScope::class.java, + ) + + @Test + fun `IoDispatcher is a qualifier`() { + val annotation = IoDispatcher::class.java + + val isQualifier = annotation.isAnnotationPresent(Qualifier::class.java) + + assertThat(isQualifier).isTrue() + } + + @Test + fun `DefaultDispatcher is a qualifier`() { + val annotation = DefaultDispatcher::class.java + + val isQualifier = annotation.isAnnotationPresent(Qualifier::class.java) + + assertThat(isQualifier).isTrue() + } + + @Test + fun `ApplicationScope is a qualifier`() { + val annotation = ApplicationScope::class.java + + val isQualifier = annotation.isAnnotationPresent(Qualifier::class.java) + + assertThat(isQualifier).isTrue() + } + + @Test + fun `every qualifier is retained in the class file`() { + val retentions = qualifiers.map { it.getAnnotation(Retention::class.java)?.value } + + assertThat(retentions).containsExactly( + RetentionPolicy.CLASS, + RetentionPolicy.CLASS, + RetentionPolicy.CLASS, + ) + } + + @Test + fun `CoroutinesModule is a Dagger module`() { + val module = CoroutinesModule::class.java + + val isModule = module.isAnnotationPresent(Module::class.java) + + assertThat(isModule).isTrue() + } +} diff --git a/core-locale/src/main/kotlin/de/jeanlucmakiola/floret/locale/AppLanguage.kt b/core-locale/src/main/kotlin/de/jeanlucmakiola/floret/locale/AppLanguage.kt index 8b1efeb..854814c 100644 --- a/core-locale/src/main/kotlin/de/jeanlucmakiola/floret/locale/AppLanguage.kt +++ b/core-locale/src/main/kotlin/de/jeanlucmakiola/floret/locale/AppLanguage.kt @@ -1,6 +1,9 @@ package de.jeanlucmakiola.floret.locale +import android.app.LocaleManager import android.content.Context +import android.os.Build +import android.os.LocaleList import androidx.annotation.XmlRes import androidx.appcompat.app.AppCompatDelegate import androidx.core.os.LocaleListCompat @@ -18,10 +21,12 @@ private const val ANDROID_NS = "http://schemas.android.com/apk/res/android" * makes the language show up here and in the system per-app-language settings, * with no other code change. The system-default choice is represented as `null`. * - * On API 33+ this delegates to the platform per-app-languages API; below that - * the appcompat backport persists the choice itself (manifest `autoStoreLocales` - * service), so we don't mirror it in DataStore. Setting a locale recreates the - * activity, which re-reads the current value for the picker. + * On API 33+ this talks to the platform [LocaleManager] directly. AppCompat's + * `setApplicationLocales` only reaches it through a live `AppCompatActivity`, so + * from a plain `ComponentActivity` it silently did nothing. Below 33 it falls back + * to the appcompat backport (manifest `autoStoreLocales` service), which only + * applies to AppCompat activities. Setting a locale recreates the activity, which + * re-reads the current value for the picker. */ object AppLanguage { @@ -52,6 +57,15 @@ object AppLanguage { } /** The applied app language as a BCP-47 tag, or `null` when following the system. */ + fun currentTag(context: Context): String? { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val locales = context.getSystemService(LocaleManager::class.java).applicationLocales + return if (locales.isEmpty) null else locales[0].toLanguageTag() + } + return currentTag() + } + + @Deprecated("No-op on API 33+ without an AppCompatActivity", ReplaceWith("currentTag(context)")) fun currentTag(): String? { val locales = AppCompatDelegate.getApplicationLocales() return if (locales.isEmpty) null else locales[0]?.toLanguageTag() @@ -66,6 +80,11 @@ object AppLanguage { * ICU and fills in the implied script, so a "zh-CN" choice reads back as * "zh-Hans-CN" and no longer string-matches the tag the picker offers. */ + fun currentTag(context: Context, supported: List): String? = + resolveTag(currentTag(context), supported) + + @Deprecated("No-op on API 33+ without an AppCompatActivity", ReplaceWith("currentTag(context, supported)")) + @Suppress("DEPRECATION") fun currentTag(supported: List): String? = resolveTag(currentTag(), supported) /** @@ -87,6 +106,17 @@ object AppLanguage { } /** Apply a BCP-47 tag, or `null` to follow the system languages. */ + fun apply(context: Context, tag: String?) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.getSystemService(LocaleManager::class.java).applicationLocales = + LocaleList.forLanguageTags(tag.orEmpty()) + return + } + @Suppress("DEPRECATION") + apply(tag) + } + + @Deprecated("No-op on API 33+ without an AppCompatActivity", ReplaceWith("apply(context, tag)")) fun apply(tag: String?) { val locales = if (tag == null) { LocaleListCompat.getEmptyLocaleList() diff --git a/core-prefs/build.gradle.kts b/core-prefs/build.gradle.kts new file mode 100644 index 0000000..b222104 --- /dev/null +++ b/core-prefs/build.gradle.kts @@ -0,0 +1,55 @@ +// core-prefs — the family's typed DataStore layer: `Pref` (a key + a default + +// its codec), `PrefStore` (typed, distinct-until-changed reads and writes over a +// `DataStore`), `toEnum()` for stored values that degrade instead of +// throwing, and the shared appearance preferences (`ThemeMode`, `dynamicColor`). +// The store's *name* and its construction stay with the consuming app. +// +// An Android library rather than a JVM one: the whole surface is typed over +// `androidx.datastore.preferences.core.DataStore` and the app needs +// `Context.preferencesDataStoreFile(...)`, which ships only in the Android +// artifact — so this module `api`-exposes it and an app gets the whole stack from +// one dependency. No Compose here, so only the Android library plugin is applied. +plugins { + alias(libs.plugins.android.library) +} + +android { + namespace = "de.jeanlucmakiola.floret.prefs" + compileSdk = 37 + + defaultConfig { + minSdk = 29 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + testOptions { + unitTests { + all { it.useJUnitPlatform() } + isReturnDefaultValues = true + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +dependencies { + // Both are in the module's public API: `DataStore` is a + // constructor parameter and `Flow` is a return type. + api(libs.androidx.datastore.preferences) + api(libs.kotlinx.coroutines.core) + + testImplementation(libs.junit.jupiter.api) + testRuntimeOnly(libs.junit.jupiter.engine) + testRuntimeOnly(libs.junit.platform.launcher) + testImplementation(libs.truth) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.turbine) +} diff --git a/core-prefs/src/main/kotlin/de/jeanlucmakiola/floret/prefs/Appearance.kt b/core-prefs/src/main/kotlin/de/jeanlucmakiola/floret/prefs/Appearance.kt new file mode 100644 index 0000000..dcb0c22 --- /dev/null +++ b/core-prefs/src/main/kotlin/de/jeanlucmakiola/floret/prefs/Appearance.kt @@ -0,0 +1,40 @@ +package de.jeanlucmakiola.floret.prefs + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map + +/** Light/dark override. [SYSTEM] follows the device setting. */ +enum class ThemeMode { SYSTEM, LIGHT, DARK } + +/** Resolve to a concrete dark flag; [SYSTEM] defers to [systemDark]. */ +fun ThemeMode.isDark(systemDark: Boolean): Boolean = when (this) { + ThemeMode.SYSTEM -> systemDark + ThemeMode.LIGHT -> false + ThemeMode.DARK -> true +} + +/** The two appearance choices every app in the family has. */ +data class Appearance( + val themeMode: ThemeMode = ThemeMode.SYSTEM, + val dynamicColor: Boolean = true, +) + +/** + * The family's shared appearance preferences. The key names are fixed at the + * values Calendula and Agendula already write — changing one is a data migration. + */ +object AppearancePrefs { + val themeMode: Pref = enumPref("theme_mode", ThemeMode.SYSTEM) + val dynamicColor: Pref = booleanPref("dynamic_color", true) +} + +/** Both appearance choices as one distinct-until-changed stream. */ +fun PrefStore.appearance(): Flow = data + .map { + Appearance( + themeMode = AppearancePrefs.themeMode.read(it), + dynamicColor = AppearancePrefs.dynamicColor.read(it), + ) + } + .distinctUntilChanged() diff --git a/core-prefs/src/main/kotlin/de/jeanlucmakiola/floret/prefs/EnumPrefs.kt b/core-prefs/src/main/kotlin/de/jeanlucmakiola/floret/prefs/EnumPrefs.kt new file mode 100644 index 0000000..4f3c151 --- /dev/null +++ b/core-prefs/src/main/kotlin/de/jeanlucmakiola/floret/prefs/EnumPrefs.kt @@ -0,0 +1,27 @@ +package de.jeanlucmakiola.floret.prefs + +import androidx.datastore.preferences.core.stringPreferencesKey + +/** Stored [Enum.name] -> entry; `null`, unknown and wrong-case fall back to [default]. */ +fun > String?.toEnum(entries: Collection, default: E): E = + entries.firstOrNull { it.name == this } ?: default + +/** Stored [Enum.name] -> entry; `null`, unknown and wrong-case fall back to [default]. */ +inline fun > String?.toEnum(default: E): E = + toEnum(enumValues().toList(), default) + +/** An enum stored by [Enum.name]; an unreadable value degrades to [default] rather than throwing. */ +fun > enumPref(name: String, default: E, entries: Collection): Pref { + val key = stringPreferencesKey(name) + return Pref( + name = name, + default = default, + key = key, + read = { it[key].toEnum(entries, default) }, + write = { preferences, value -> preferences[key] = value.name }, + ) +} + +/** An enum stored by [Enum.name]; an unreadable value degrades to [default] rather than throwing. */ +inline fun > enumPref(name: String, default: E): Pref = + enumPref(name, default, enumValues().toList()) diff --git a/core-prefs/src/main/kotlin/de/jeanlucmakiola/floret/prefs/Pref.kt b/core-prefs/src/main/kotlin/de/jeanlucmakiola/floret/prefs/Pref.kt new file mode 100644 index 0000000..785660b --- /dev/null +++ b/core-prefs/src/main/kotlin/de/jeanlucmakiola/floret/prefs/Pref.kt @@ -0,0 +1,84 @@ +package de.jeanlucmakiola.floret.prefs + +import androidx.datastore.preferences.core.MutablePreferences +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.floatPreferencesKey +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.core.stringSetPreferencesKey + +/** + * One typed preference: its on-disk key name, its default, and the pair of + * functions that read it out of / write it into a [Preferences] snapshot. + * Instances come from the factory functions below or from [map]; the + * constructor is internal so the stored representation stays this module's. + */ +class Pref internal constructor( + val name: String, + val default: T, + internal val key: Preferences.Key<*>, + internal val read: (Preferences) -> T, + internal val write: (MutablePreferences, T) -> Unit, +) { + /** A view of this preference as [R]; shares the key, derives the default through [decode]. */ + fun map(decode: (T) -> R, encode: (R) -> T): Pref = Pref( + name = name, + default = decode(default), + key = key, + read = { decode(read(it)) }, + write = { preferences, value -> write(preferences, encode(value)) }, + ) +} + +fun booleanPref(name: String, default: Boolean): Pref = + scalarPref(booleanPreferencesKey(name), name, default) + +fun intPref(name: String, default: Int): Pref = + scalarPref(intPreferencesKey(name), name, default) + +fun longPref(name: String, default: Long): Pref = + scalarPref(longPreferencesKey(name), name, default) + +fun floatPref(name: String, default: Float): Pref = + scalarPref(floatPreferencesKey(name), name, default) + +fun stringPref(name: String, default: String): Pref = + scalarPref(stringPreferencesKey(name), name, default) + +fun stringSetPref(name: String, default: Set = emptySet()): Pref> = + scalarPref(stringSetPreferencesKey(name), name, default) + +/** Absent reads back as `null`; writing `null` removes the key. */ +fun nullableStringPref(name: String): Pref = + nullablePref(stringPreferencesKey(name), name) + +/** Absent reads back as `null`; writing `null` removes the key. */ +fun nullableLongPref(name: String): Pref = + nullablePref(longPreferencesKey(name), name) + +/** An absent key reads back as [default]; the value is stored under [key] verbatim. */ +private fun scalarPref(key: Preferences.Key, name: String, default: T): Pref = + Pref( + name = name, + default = default, + key = key, + read = { it[key] ?: default }, + write = { preferences, value -> preferences[key] = value }, + ) + +/** + * Absence *is* the value here, so there is no default to fall back to and + * writing `null` removes the key rather than storing a sentinel for it. + */ +private fun nullablePref(key: Preferences.Key, name: String): Pref = + Pref( + name = name, + default = null, + key = key, + read = { it[key] }, + write = { preferences, value -> + if (value == null) preferences.remove(key) else preferences[key] = value + }, + ) diff --git a/core-prefs/src/main/kotlin/de/jeanlucmakiola/floret/prefs/PrefStore.kt b/core-prefs/src/main/kotlin/de/jeanlucmakiola/floret/prefs/PrefStore.kt new file mode 100644 index 0000000..8aa06eb --- /dev/null +++ b/core-prefs/src/main/kotlin/de/jeanlucmakiola/floret/prefs/PrefStore.kt @@ -0,0 +1,48 @@ +package de.jeanlucmakiola.floret.prefs + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.MutablePreferences +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +/** + * The typed view over a DataStore. The store's *name* and its construction stay + * with the app; this owns only the typed read/write vocabulary. + */ +class PrefStore(private val dataStore: DataStore) { + + /** The raw snapshot stream — the escape hatch for composing several keys in one map. */ + val data: Flow get() = dataStore.data + + /** [pref]'s value, distinct-until-changed, so an unrelated key's write doesn't re-emit. */ + fun flow(pref: Pref): Flow = dataStore.data.map(pref.read).distinctUntilChanged() + + suspend fun get(pref: Pref): T = pref.read(dataStore.data.first()) + + suspend fun set(pref: Pref, value: T) { + dataStore.edit { pref.write(it, value) } + } + + /** Atomic read-modify-write; returns the stored value after the edit. */ + suspend fun update(pref: Pref, transform: (T) -> T): T = + pref.read(dataStore.edit { pref.write(it, transform(pref.read(it))) }) + + /** Removes [pref]'s key, so it reads back as its default. */ + suspend fun clear(pref: Pref) { + dataStore.edit { it.remove(pref.key) } + } + + /** Removes every key in the store. */ + suspend fun clearAll() { + dataStore.edit { it.clear() } + } + + /** Raw multi-key edit, for the cases the typed API can't express. */ + suspend fun edit(transform: suspend (MutablePreferences) -> Unit) { + dataStore.edit { transform(it) } + } +} diff --git a/core-prefs/src/test/kotlin/de/jeanlucmakiola/floret/prefs/AppearancePrefsTest.kt b/core-prefs/src/test/kotlin/de/jeanlucmakiola/floret/prefs/AppearancePrefsTest.kt new file mode 100644 index 0000000..4b55eba --- /dev/null +++ b/core-prefs/src/test/kotlin/de/jeanlucmakiola/floret/prefs/AppearancePrefsTest.kt @@ -0,0 +1,96 @@ +package de.jeanlucmakiola.floret.prefs + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path + +/** + * The family's shared appearance preferences. The key names are the on-disk + * contract with Calendula and Agendula, so they are asserted literally. + */ +class AppearancePrefsTest { + + private fun TestScope.newStore(tempDir: Path): PrefStore = PrefStore( + PreferenceDataStoreFactory.create( + scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler) + Job()), + produceFile = { tempDir.resolve("test.preferences_pb").toFile() }, + ), + ) + + @Test + fun `the theme mode pref is stored under theme_mode and follows the system by default`() { + val pref = AppearancePrefs.themeMode + + assertThat(pref.name to pref.default).isEqualTo("theme_mode" to ThemeMode.SYSTEM) + } + + @Test + fun `the dynamic colour pref is stored under dynamic_color and defaults to on`() { + val pref = AppearancePrefs.dynamicColor + + assertThat(pref.name to pref.default).isEqualTo("dynamic_color" to true) + } + + @Test + fun `a fresh store reports the family's default appearance`(@TempDir tempDir: Path) = runTest { + val store = newStore(tempDir) + + val appearance = store.appearance().first() + + assertThat(appearance).isEqualTo(Appearance(ThemeMode.SYSTEM, dynamicColor = true)) + } + + @Test + fun `a stored theme mode shows up in the appearance`(@TempDir tempDir: Path) = runTest { + val store = newStore(tempDir) + + store.set(AppearancePrefs.themeMode, ThemeMode.DARK) + + assertThat(store.appearance().first()) + .isEqualTo(Appearance(ThemeMode.DARK, dynamicColor = true)) + } + + @Test + fun `a stored dynamic colour choice shows up in the appearance`(@TempDir tempDir: Path) = runTest { + val store = newStore(tempDir) + + store.set(AppearancePrefs.dynamicColor, false) + + assertThat(store.appearance().first()) + .isEqualTo(Appearance(ThemeMode.SYSTEM, dynamicColor = false)) + } + + @Test + fun `an appearance a sibling app wrote is read back`(@TempDir tempDir: Path) = runTest { + val store = newStore(tempDir) + + store.edit { it[stringPreferencesKey("theme_mode")] = "LIGHT" } + + assertThat(store.appearance().first().themeMode).isEqualTo(ThemeMode.LIGHT) + } + + @Test + fun `the appearance stream does not re-emit for an unrelated key`(@TempDir tempDir: Path) = runTest { + val store = newStore(tempDir) + + store.appearance().test { + awaitItem() + + store.edit { it[intPreferencesKey("unrelated")] = 1 } + + expectNoEvents() + cancel() + } + } +} diff --git a/core-prefs/src/test/kotlin/de/jeanlucmakiola/floret/prefs/EnumParsingTest.kt b/core-prefs/src/test/kotlin/de/jeanlucmakiola/floret/prefs/EnumParsingTest.kt new file mode 100644 index 0000000..ed8a7c8 --- /dev/null +++ b/core-prefs/src/test/kotlin/de/jeanlucmakiola/floret/prefs/EnumParsingTest.kt @@ -0,0 +1,65 @@ +package de.jeanlucmakiola.floret.prefs + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +/** + * `toEnum` is the read path for every enum-valued preference: whatever is on disk, + * it has to produce an entry rather than throw. + */ +class EnumParsingTest { + + @Test + fun `toEnum returns the entry whose name matches exactly`() { + val stored = "DARK" + + val parsed = stored.toEnum(ThemeMode.SYSTEM) + + assertThat(parsed).isEqualTo(ThemeMode.DARK) + } + + @Test + fun `toEnum falls back to the default when nothing is stored`() { + val stored: String? = null + + val parsed = stored.toEnum(ThemeMode.SYSTEM) + + assertThat(parsed).isEqualTo(ThemeMode.SYSTEM) + } + + @Test + fun `toEnum falls back to the default for an unknown value`() { + val stored = "FUNDAY" + + val parsed = stored.toEnum(ThemeMode.SYSTEM) + + assertThat(parsed).isEqualTo(ThemeMode.SYSTEM) + } + + @Test + fun `toEnum falls back to the default for the empty string`() { + val stored = "" + + val parsed = stored.toEnum(ThemeMode.SYSTEM) + + assertThat(parsed).isEqualTo(ThemeMode.SYSTEM) + } + + @Test + fun `toEnum matching is case sensitive`() { + val stored = "dark" + + val parsed = stored.toEnum(ThemeMode.SYSTEM) + + assertThat(parsed).isEqualTo(ThemeMode.SYSTEM) + } + + @Test + fun `toEnum round-trips every entry name`() { + val entries = ThemeMode.entries + + val parsed = entries.map { it.name.toEnum(ThemeMode.SYSTEM) } + + assertThat(parsed).containsExactlyElementsIn(entries).inOrder() + } +} diff --git a/core-prefs/src/test/kotlin/de/jeanlucmakiola/floret/prefs/PrefStoreTest.kt b/core-prefs/src/test/kotlin/de/jeanlucmakiola/floret/prefs/PrefStoreTest.kt new file mode 100644 index 0000000..ea34046 --- /dev/null +++ b/core-prefs/src/test/kotlin/de/jeanlucmakiola/floret/prefs/PrefStoreTest.kt @@ -0,0 +1,284 @@ +package de.jeanlucmakiola.floret.prefs + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path + +/** + * The typed read/write vocabulary over a real DataStore, one temp file per test. + * The store runs on the test scheduler so every write and the emission it causes + * are ordered against the assertions. + */ +class PrefStoreTest { + + private fun TestScope.newStore(tempDir: Path): PrefStore = PrefStore( + PreferenceDataStoreFactory.create( + scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler) + Job()), + produceFile = { tempDir.resolve("test.preferences_pb").toFile() }, + ), + ) + + @Test + fun `an unwritten boolean reads back as its default`(@TempDir tempDir: Path) = runTest { + val store = newStore(tempDir) + val pref = booleanPref("b", true) + + val value = store.get(pref) + + assertThat(value).isTrue() + } + + @Test + fun `a boolean round-trips`(@TempDir tempDir: Path) = runTest { + val store = newStore(tempDir) + val pref = booleanPref("b", true) + + store.set(pref, false) + + assertThat(store.get(pref)).isFalse() + } + + @Test + fun `an int reads its default then a written negative value`(@TempDir tempDir: Path) = runTest { + val store = newStore(tempDir) + val pref = intPref("i", 7) + assertThat(store.get(pref)).isEqualTo(7) + + store.set(pref, -3) + + assertThat(store.get(pref)).isEqualTo(-3) + } + + @Test + fun `a long round-trips its maximum value`(@TempDir tempDir: Path) = runTest { + val store = newStore(tempDir) + val pref = longPref("l", 0L) + + store.set(pref, Long.MAX_VALUE) + + assertThat(store.get(pref)).isEqualTo(Long.MAX_VALUE) + } + + @Test + fun `a float round-trips`(@TempDir tempDir: Path) = runTest { + val store = newStore(tempDir) + val pref = floatPref("f", 1f) + + store.set(pref, 63.5f) + + assertThat(store.get(pref)).isEqualTo(63.5f) + } + + @Test + fun `an empty string is a stored value, not an absent one`(@TempDir tempDir: Path) = runTest { + val store = newStore(tempDir) + val pref = stringPref("s", "fallback") + assertThat(store.get(pref)).isEqualTo("fallback") + + store.set(pref, "") + + assertThat(store.get(pref)).isEmpty() + } + + @Test + fun `a string set reads its default then exactly what was written`(@TempDir tempDir: Path) = runTest { + val store = newStore(tempDir) + val pref = stringSetPref("set", setOf("a")) + assertThat(store.get(pref)).containsExactly("a") + + store.set(pref, setOf("x", "y")) + + assertThat(store.get(pref)).containsExactly("x", "y") + } + + @Test + fun `an explicitly stored empty set is distinct from the default`(@TempDir tempDir: Path) = runTest { + val store = newStore(tempDir) + val pref = stringSetPref("set", setOf("a")) + + store.set(pref, emptySet()) + + assertThat(store.get(pref)).isEmpty() + } + + @Test + fun `an enum round-trips`(@TempDir tempDir: Path) = runTest { + val store = newStore(tempDir) + val pref = enumPref("mode", ThemeMode.SYSTEM) + + store.set(pref, ThemeMode.LIGHT) + + assertThat(store.get(pref)).isEqualTo(ThemeMode.LIGHT) + } + + @Test + fun `an unknown stored enum name degrades to the default`(@TempDir tempDir: Path) = runTest { + val store = newStore(tempDir) + val pref = enumPref("mode", ThemeMode.SYSTEM) + + store.edit { it[stringPreferencesKey("mode")] = "NOPE" } + + assertThat(store.get(pref)).isEqualTo(ThemeMode.SYSTEM) + } + + @Test + fun `an empty stored enum name degrades to the default`(@TempDir tempDir: Path) = runTest { + val store = newStore(tempDir) + val pref = enumPref("mode", ThemeMode.SYSTEM) + + store.edit { it[stringPreferencesKey("mode")] = "" } + + assertThat(store.get(pref)).isEqualTo(ThemeMode.SYSTEM) + } + + @Test + fun `a nullable string reads null when unset and the value once written`(@TempDir tempDir: Path) = runTest { + val store = newStore(tempDir) + val pref = nullableStringPref("opt") + assertThat(store.get(pref)).isNull() + + store.set(pref, "v") + + assertThat(store.get(pref)).isEqualTo("v") + } + + @Test + fun `writing null to a nullable string removes the key`(@TempDir tempDir: Path) = runTest { + val store = newStore(tempDir) + val pref = nullableStringPref("opt") + store.set(pref, "v") + + store.set(pref, null) + + assertThat(store.data.first().contains(stringPreferencesKey("opt"))).isFalse() + } + + @Test + fun `writing null to a nullable long removes the key`(@TempDir tempDir: Path) = runTest { + val store = newStore(tempDir) + val pref = nullableLongPref("id") + store.set(pref, 5L) + + store.set(pref, null) + + assertThat(store.data.first().contains(longPreferencesKey("id"))).isFalse() + } + + @Test + fun `clear restores a single pref to its default`(@TempDir tempDir: Path) = runTest { + val store = newStore(tempDir) + val pref = intPref("i", 7) + store.set(pref, 42) + + store.clear(pref) + + assertThat(store.get(pref)).isEqualTo(7) + } + + @Test + fun `clearAll restores every pref to its default`(@TempDir tempDir: Path) = runTest { + val store = newStore(tempDir) + val flag = booleanPref("b", true) + val count = intPref("i", 7) + val label = stringPref("s", "fallback") + store.set(flag, false) + store.set(count, 42) + store.set(label, "written") + + store.clearAll() + + assertThat(listOf(store.get(flag), store.get(count), store.get(label))) + .containsExactly(true, 7, "fallback").inOrder() + } + + @Test + fun `update transforms the stored value`(@TempDir tempDir: Path) = runTest { + val store = newStore(tempDir) + val pref = intPref("n", 0) + store.set(pref, 5) + + val returned = store.update(pref) { it + 1 } + + assertThat(listOf(returned, store.get(pref))).containsExactly(6, 6) + } + + @Test + fun `update of an unset pref transforms its default`(@TempDir tempDir: Path) = runTest { + val store = newStore(tempDir) + val pref = intPref("n", 10) + + val returned = store.update(pref) { it * 2 } + + assertThat(returned).isEqualTo(20) + } + + @Test + fun `a flow does not re-emit when an unrelated key is written`(@TempDir tempDir: Path) = runTest { + val store = newStore(tempDir) + val watched = booleanPref("watched", false) + val other = intPref("other", 0) + + store.flow(watched).test { + assertThat(awaitItem()).isFalse() + + store.set(other, 99) + + expectNoEvents() + cancel() + } + } + + @Test + fun `a flow does not re-emit when the same value is written again`(@TempDir tempDir: Path) = runTest { + val store = newStore(tempDir) + val watched = booleanPref("watched", false) + store.set(watched, true) + + store.flow(watched).test { + assertThat(awaitItem()).isTrue() + + store.set(watched, true) + + expectNoEvents() + cancel() + } + } + + @Test + fun `a flow emits exactly one item carrying a changed value`(@TempDir tempDir: Path) = runTest { + val store = newStore(tempDir) + val watched = booleanPref("watched", false) + + store.flow(watched).test { + awaitItem() + + store.set(watched, true) + + assertThat(awaitItem()).isTrue() + expectNoEvents() + cancel() + } + } + + @Test + fun `a typed pref and its raw key name are the same storage slot`(@TempDir tempDir: Path) = runTest { + val store = newStore(tempDir) + val pref = booleanPref("b", true) + + store.edit { it[booleanPreferencesKey("b")] = false } + + assertThat(store.get(pref)).isFalse() + } +} diff --git a/core-prefs/src/test/kotlin/de/jeanlucmakiola/floret/prefs/PrefTest.kt b/core-prefs/src/test/kotlin/de/jeanlucmakiola/floret/prefs/PrefTest.kt new file mode 100644 index 0000000..4ec96d3 --- /dev/null +++ b/core-prefs/src/test/kotlin/de/jeanlucmakiola/floret/prefs/PrefTest.kt @@ -0,0 +1,71 @@ +package de.jeanlucmakiola.floret.prefs + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +/** + * The `Pref` factories, as metadata only — a name and a default, with no store + * behind them. + */ +class PrefTest { + + @Test + fun `booleanPref reports its name and default`() { + val pref = booleanPref("flag", true) + + assertThat(pref.name to pref.default).isEqualTo("flag" to true) + } + + @Test + fun `the scalar factories each report their name and default`() { + val prefs = listOf( + intPref("i", 7), + longPref("l", 9L), + floatPref("f", 1.5f), + stringPref("s", "fallback"), + stringSetPref("set", setOf("a")), + ) + + val described = prefs.map { it.name to it.default } + + assertThat(described).containsExactly( + "i" to 7, + "l" to 9L, + "f" to 1.5f, + "s" to "fallback", + "set" to setOf("a"), + ).inOrder() + } + + @Test + fun `stringSetPref defaults to the empty set`() { + val pref = stringSetPref("s") + + assertThat(pref.default).isEmpty() + } + + @Test + fun `the nullable factories default to null`() { + val prefs = listOf(nullableStringPref("s"), nullableLongPref("l")) + + val defaults = prefs.map { it.default } + + assertThat(defaults).containsExactly(null, null) + } + + @Test + fun `enumPref reports its name and default entry`() { + val pref = enumPref("mode", ThemeMode.DARK) + + assertThat(pref.name to pref.default).isEqualTo("mode" to ThemeMode.DARK) + } + + @Test + fun `map keeps the name and derives the default through the decoder`() { + val pref = intPref("n", 60) + + val mapped = pref.map({ it.toString() }, { it.toInt() }) + + assertThat(mapped.name to mapped.default).isEqualTo("n" to "60") + } +} diff --git a/core-prefs/src/test/kotlin/de/jeanlucmakiola/floret/prefs/ThemeModeTest.kt b/core-prefs/src/test/kotlin/de/jeanlucmakiola/floret/prefs/ThemeModeTest.kt new file mode 100644 index 0000000..d42d47e --- /dev/null +++ b/core-prefs/src/test/kotlin/de/jeanlucmakiola/floret/prefs/ThemeModeTest.kt @@ -0,0 +1,51 @@ +package de.jeanlucmakiola.floret.prefs + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +/** The stored theme choice resolved against the device's light/dark setting. */ +class ThemeModeTest { + + @Test + fun `isDark follows a dark system when the mode is SYSTEM`() { + val mode = ThemeMode.SYSTEM + + val dark = mode.isDark(systemDark = true) + + assertThat(dark).isTrue() + } + + @Test + fun `isDark follows a light system when the mode is SYSTEM`() { + val mode = ThemeMode.SYSTEM + + val dark = mode.isDark(systemDark = false) + + assertThat(dark).isFalse() + } + + @Test + fun `isDark lets LIGHT override a dark system`() { + val mode = ThemeMode.LIGHT + + val dark = mode.isDark(systemDark = true) + + assertThat(dark).isFalse() + } + + @Test + fun `isDark lets DARK override a light system`() { + val mode = ThemeMode.DARK + + val dark = mode.isDark(systemDark = false) + + assertThat(dark).isTrue() + } + + @Test + fun `Appearance defaults to following the system with dynamic colour on`() { + val appearance = Appearance() + + assertThat(appearance).isEqualTo(Appearance(ThemeMode.SYSTEM, dynamicColor = true)) + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index db85bef..dfb1413 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -74,6 +74,8 @@ should scan the submodule's Gradle scripts (see §5). |---|---|---| | Plumbing | `core-time` (JVM) | Pure logic; no Android, no deps. | | Plumbing | `core-crash` (Android) | Self-contained subsystem; parameterized per app. | +| Plumbing | `core-prefs` (Android) | Typed DataStore layer. The store's name and construction stay with the app. | +| Plumbing | `core-di` (JVM) | Dispatcher/scope qualifiers + their Hilt module. The only module that knows about DI. | | Design — identity | `identity` (Android) | Theme factory + motion. App supplies seed/palette. | | Design — components | `components` (Android) | Shared Compose primitives. App composes screens. | @@ -93,7 +95,7 @@ Two kinds of module: - **JVM library** (`core-time`) — `org.jetbrains.kotlin.jvm`, `jvmTarget = 17`, JUnit 5 + Truth tests. The right home for pure logic; cheapest to build and test. -- **Android library** (`core-crash`, `identity`, `components`) — +- **Android library** (`core-crash`, `core-prefs`, `identity`, `components`) — `com.android.library` + the Compose plugin, `compileSdk 37` / `minSdk 29`. > **AGP 9.x provides built-in Kotlin compilation.** Android modules therefore @@ -101,6 +103,18 @@ Two kinds of module: > **not** a separate `org.jetbrains.kotlin.android` plugin (applying it errors). > They set `jvmTarget` directly in a `kotlin { compilerOptions { … } }` block. +> **An Android module with no Compose in it applies only `com.android.library`** +> — `core-prefs` is the first such module. The Compose plugin is added per +> module, when the module actually has composables. + +> **`core-di` is the one module carrying an annotation processor** — the KSP +> plugin plus `hilt-compiler`. A `@Module @InstallIn(...)` only reaches a +> consuming app's Hilt component if the *library* is processed: the app's +> processor reads `@AggregatedDeps` metadata off the classpath, it does not +> reprocess library sources. As a non-Android module it uses `hilt-core` + +> `hilt-compiler` rather than the Android Hilt Gradle plugin. Every other module +> stays DI-free. + Versions live in [`gradle/libs.versions.toml`](../gradle/libs.versions.toml), kept in lockstep with the apps' catalogs. diff --git a/docs/README.md b/docs/README.md index 952ea18..9c5964f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -3,7 +3,8 @@ floret-kit is the shared **Material 3 Expressive design system + plumbing** for the Floret family of Android apps ([Calendula](https://gitea.jeanlucmakiola.de/makiolaj/calendula), -[Agendula](https://gitea.jeanlucmakiola.de/makiolaj/agendula), and future +[Agendula](https://gitea.jeanlucmakiola.de/makiolaj/agendula), +[Clockula](https://gitea.jeanlucmakiola.de/makiolaj/clockula), and future siblings). It is consumed from source as a git submodule + Gradle composite build. See the top-level [`../README.md`](../README.md) for the pitch. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 34f6159..0f57ac4 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -15,20 +15,28 @@ divergent or app-specific code where it lives. | `core-reminders` | JVM | Agendula (and Calendula's pending branch) | | `core-locale` | Android | Agendula, Calendula | | `core-crash` | Android | Agendula | +| `core-prefs` | Android | Clockula | +| `core-di` | JVM | Clockula | | `identity` | Android | Agendula | | `components` | Android | Agendula | +| `glance` | Android | Agendula (Calendula pending — its own M3 Expressive widget pass) | See [`../CHANGELOG.md`](../CHANGELOG.md) for the contents of each. +### Follow-ups on what's extracted + +- **Migrate Calendula and Agendula onto `core-prefs` + `core-di`** — Clockula + triggered the extraction (the third app that made the duplication hurt) and is + its only consumer today. Both siblings still carry their own `ThemeMode`, + `toEnum()` and `@IoDispatcher`. The stored key names are identical + (`theme_mode`, `dynamic_color`), so each migration is a code change with **no + data migration** — deliberately out of scope for the extraction, and done by + each sibling when it next re-pins the kit. + --- ## Deferred / not yet worth it -- **`core-prefs`** (DataStore base: `ThemeMode`, `dynamicColor`, the typed - wrapper, `toEnum()`) and **`core-di`** (`@IoDispatcher` + provider) — genuinely - shared but **low value**: pure relocation, no new capability, the shared - surface is tiny once the store name and bindings stay app-local. Sensible to - defer until a third app makes the duplication hurt. - **`ReminderFormatting`** (`ReminderUnit` + `reminderLeadTimeLabel` + `decomposeReminder`) — the labels are `@Composable` over app-specific `R.plurals`, and the presets differ per app. Extract later behind a @@ -66,6 +74,8 @@ See [`../CHANGELOG.md`](../CHANGELOG.md) for the contents of each. ## Consumers & cadence - **Agendula** consumes the kit on `main` (active development). +- **Clockula** consumes the kit on `main` (active development); the first + consumer of `core-prefs` and `core-di`. - **Calendula** (ships via official F-Droid reproducible builds) has a pending adoption branch. Bringing it onto the kit is where the canonical-`GroupedSurface` convergence becomes visible, and where the F-Droid reproducibility checklist diff --git a/glance/build.gradle.kts b/glance/build.gradle.kts new file mode 100644 index 0000000..94d3771 --- /dev/null +++ b/glance/build.gradle.kts @@ -0,0 +1,63 @@ +// glance — the family's shared home-screen-widget plumbing and M3 Expressive +// widget vocabulary, built on Jetpack Glance: the theme factory, the rounded +// WidgetSurface (solid/tinted/transparent backgrounds with the system corner +// radius), a header + expressive action-button recipe, a position-aware +// grouped-row primitive for Glance (cornerRadius has no per-corner overload, so +// this comes from four shape drawables instead), and the midnight-rollover + +// update-receiver plumbing every widget needs. App-agnostic: each app supplies +// its own colour schemes, data and receivers. +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "de.jeanlucmakiola.floret.glance" + compileSdk = 37 + + defaultConfig { + minSdk = 29 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + buildFeatures { + compose = true + } + + testOptions { + unitTests { + all { it.useJUnitPlatform() } + isReturnDefaultValues = true + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +dependencies { + // components supplies Position/positionOf (the row-shape math) and pastelize. + implementation(project(":components")) + + implementation(libs.androidx.core.ktx) + implementation(libs.kotlinx.datetime) + + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.foundation) + implementation(libs.androidx.material3) + implementation(libs.androidx.glance.appwidget) + implementation(libs.androidx.glance.material3) + + testImplementation(libs.junit.jupiter.api) + testRuntimeOnly(libs.junit.jupiter.engine) + testRuntimeOnly(libs.junit.platform.launcher) + testImplementation(libs.truth) +} diff --git a/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/FloretGlanceTheme.kt b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/FloretGlanceTheme.kt new file mode 100644 index 0000000..9373922 --- /dev/null +++ b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/FloretGlanceTheme.kt @@ -0,0 +1,45 @@ +package de.jeanlucmakiola.floret.glance + +import android.os.Build +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.runtime.Composable +import androidx.glance.GlanceTheme +import androidx.glance.LocalContext +import androidx.glance.material3.ColorProviders + +/** + * A widget's own light/dark decision, independent of the app's night-resource + * qualifier: [SYSTEM] follows the device, [LIGHT]/[DARK] pin one regardless. + * An app's per-widget "Light" / "Dark" override maps straight to these; its + * "Follow app" option resolves to whichever of the three the app's own theme + * setting currently means, then passes that resolved value in. + */ +enum class WidgetThemeMode { SYSTEM, LIGHT, DARK } + +/** + * Glance counterpart of [FloretExpressiveTheme][de.jeanlucmakiola.floret.identity.FloretExpressiveTheme]: + * Material You on API 31+ when [dynamicColor] is on, the app-supplied + * [lightScheme] / [darkScheme] otherwise. On [WidgetThemeMode.SYSTEM] it draws + * only from colour-role tokens (`GlanceTheme.colors`), so light/dark follows the + * system without a redraw; [WidgetThemeMode.LIGHT] / [WidgetThemeMode.DARK] pin + * one scheme for both themes. + */ +@Composable +fun FloretGlanceTheme( + lightScheme: ColorScheme, + darkScheme: ColorScheme, + dynamicColor: Boolean, + mode: WidgetThemeMode, + content: @Composable () -> Unit, +) { + val dynamic = dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + val context = LocalContext.current + val colors = when (mode) { + WidgetThemeMode.SYSTEM -> if (dynamic) GlanceTheme.colors else ColorProviders(light = lightScheme, dark = darkScheme) + WidgetThemeMode.LIGHT -> ColorProviders(if (dynamic) dynamicLightColorScheme(context) else lightScheme) + WidgetThemeMode.DARK -> ColorProviders(if (dynamic) dynamicDarkColorScheme(context) else darkScheme) + } + GlanceTheme(colors = colors, content = content) +} diff --git a/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/FloretWidgetUpdateReceiver.kt b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/FloretWidgetUpdateReceiver.kt new file mode 100644 index 0000000..6789049 --- /dev/null +++ b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/FloretWidgetUpdateReceiver.kt @@ -0,0 +1,72 @@ +package de.jeanlucmakiola.floret.glance + +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch + +/** + * Redraws an app's widgets when their day or language goes stale, and keeps + * [WidgetRollover]'s midnight alarm armed. A subclass supplies the receiver(s) + * to check for placed instances, the rollover action + `PendingIntent`, how to + * redraw, and — for a widget whose data lives outside the app (a calendar + * provider, say) — any extra action that should trigger a redraw without + * re-arming the alarm, via [extraHandledActions]. + * + * Exported for the system broadcasts; an extra redraw from another app is + * harmless. + */ +abstract class FloretWidgetUpdateReceiver : BroadcastReceiver() { + + /** The action this app's own rollover alarm fires with. */ + protected abstract val rolloverAction: String + + /** Every widget receiver class whose placed instances this app's widgets span. */ + protected abstract val widgetReceiverClasses: List> + + /** Built once per call, so each firing targets the same alarm as the last. */ + protected abstract fun rolloverPendingIntent(context: Context): PendingIntent + + /** Redraws every widget this receiver is responsible for. */ + protected abstract suspend fun redraw(context: Context) + + /** Actions, beyond the shared set, that should redraw (never re-arm) — e.g. a content-provider change. */ + protected open val extraHandledActions: Set = emptySet() + + // `by lazy`, not eager: an eager initializer runs during *this* class's + // construction, before the subclass's `override val rolloverAction` + // initializer has run. `internal` (not `private`) so the wiring is directly + // testable without reflecting into a `Lazy` delegate field. + private val rearmOnlyActions = setOf(Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED) + internal val rearmActions by lazy { + rearmOnlyActions + setOf( + rolloverAction, + Intent.ACTION_TIME_CHANGED, + Intent.ACTION_TIMEZONE_CHANGED, + Intent.ACTION_DATE_CHANGED, + ) + } + internal val handledActions by lazy { rearmActions + Intent.ACTION_LOCALE_CHANGED + extraHandledActions } + + final override fun onReceive(context: Context, intent: Intent) { + if (intent.action !in handledActions) return + val appContext = context.applicationContext + val rearm = intent.action in rearmActions + val redraw = intent.action !in rearmOnlyActions + val pending = goAsync() + CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { + try { + if (rearm) WidgetRollover.sync(appContext, widgetReceiverClasses, ::rolloverPendingIntent) + if (redraw && WidgetRollover.hasPlacedWidgets(appContext, widgetReceiverClasses)) { + redraw(appContext) + } + } finally { + pending.finish() + } + } + } +} diff --git a/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetCheckButton.kt b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetCheckButton.kt new file mode 100644 index 0000000..39f223e --- /dev/null +++ b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetCheckButton.kt @@ -0,0 +1,51 @@ +package de.jeanlucmakiola.floret.glance + +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import androidx.glance.ColorFilter +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.action.Action +import androidx.glance.action.clickable +import androidx.glance.appwidget.cornerRadius +import androidx.glance.background +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.size +import androidx.glance.unit.ColorProvider + +/** + * A task row's leading "complete" tap target: a round hit area around the check + * glyph. [checked] fills it with `primary` — the frame a widget shows between + * the tap and the row going away, since RemoteViews can't animate the change. + */ +@Composable +fun WidgetCheckButton( + contentDescription: String, + action: Action, + tint: ColorProvider, + modifier: GlanceModifier = GlanceModifier, + checked: Boolean = false, +) { + val filled = if (checked) GlanceModifier.background(GlanceTheme.colors.primary) else GlanceModifier + Box( + modifier = modifier + .size(TargetSize) + .cornerRadius(TargetSize / 2) + .then(filled) + .clickable(action), + contentAlignment = Alignment.Center, + ) { + Image( + provider = ImageProvider(R.drawable.floret_ic_widget_check), + contentDescription = contentDescription, + colorFilter = ColorFilter.tint(if (checked) GlanceTheme.colors.onPrimary else tint), + modifier = GlanceModifier.size(IconSize), + ) + } +} + +private val TargetSize = 28.dp +private val IconSize = 16.dp diff --git a/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetDoneState.kt b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetDoneState.kt new file mode 100644 index 0000000..b0f215c --- /dev/null +++ b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetDoneState.kt @@ -0,0 +1,59 @@ +package de.jeanlucmakiola.floret.glance + +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.appwidget.cornerRadius +import androidx.glance.background +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.Column +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.padding +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextAlign +import androidx.glance.text.TextStyle + +/** + * A list widget's "nothing left" state, framed as an achievement rather than + * an absence: [title] and an optional [subtitle] in a tertiary card. Sits in + * its own container, like [WidgetMessage], so it stays readable on + * [WidgetBackground.TRANSPARENT]. + */ +@Composable +fun WidgetDoneState(title: String, subtitle: String? = null, modifier: GlanceModifier = GlanceModifier) { + Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column( + modifier = GlanceModifier + .cornerRadius(24.dp) + .background(GlanceTheme.colors.tertiaryContainer) + .padding(horizontal = 20.dp, vertical = 14.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = title, + maxLines = 1, + style = TextStyle( + color = GlanceTheme.colors.onTertiaryContainer, + fontSize = 17.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + ), + ) + subtitle?.let { + Text( + text = it, + maxLines = 2, + style = TextStyle( + color = GlanceTheme.colors.onTertiaryContainer, + fontSize = 13.sp, + textAlign = TextAlign.Center, + ), + ) + } + } + } +} diff --git a/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetGroupedRow.kt b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetGroupedRow.kt new file mode 100644 index 0000000..529112f --- /dev/null +++ b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetGroupedRow.kt @@ -0,0 +1,58 @@ +package de.jeanlucmakiola.floret.glance + +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import androidx.glance.ColorFilter +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.ImageProvider +import androidx.glance.background +import androidx.glance.layout.Box +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.padding +import androidx.glance.unit.ColorProvider +import de.jeanlucmakiola.floret.components.Position + +/** + * The Glance counterpart of `components`' `GroupedSurface`: a tonal container + * whose corners come from [position], so a run of rows reads as one rounded + * card. Glance's `cornerRadius` modifier takes one radius for the whole shape — + * no per-corner overload — so the asymmetric shapes are four tinted drawables + * (`floret_widget_row_*`) rather than a computed + * [androidx.compose.ui.graphics.Shape]. There is no press-morph here (Glance has + * no interaction source); the radii match + * [de.jeanlucmakiola.floret.components.groupedShape]'s resting values. + * + * [color] defaults to `surfaceVariant` — the nearest tonal-card role Glance's + * `ColorProviders` exposes (no `surfaceContainerHigh` there). On a [WidgetBackground.TRANSPARENT] + * root this container is what keeps the row's text readable over an arbitrary + * wallpaper (calendula#292): every row stays opaque regardless of the surface. + */ +@Composable +fun WidgetGroupedRow( + position: Position, + modifier: GlanceModifier = GlanceModifier, + color: ColorProvider = GlanceTheme.colors.surfaceVariant, + content: @Composable () -> Unit, +) { + val shape = when (position) { + Position.Alone -> R.drawable.floret_widget_row_alone + Position.Top -> R.drawable.floret_widget_row_top + Position.Middle -> R.drawable.floret_widget_row_middle + Position.Bottom -> R.drawable.floret_widget_row_bottom + } + val gap = if (position == Position.Top || position == Position.Middle) 2.dp else 0.dp + // The gap has to live on a Box with no background of its own: Android draws a + // View's background to its full bounds regardless of that View's own padding + // (padding only insets children), so putting both modifiers on one Box left + // the tinted shape covering the gap and rows touching. + Box(modifier = modifier.fillMaxWidth().padding(bottom = gap)) { + Box( + modifier = GlanceModifier + .fillMaxWidth() + .background(ImageProvider(shape), colorFilter = ColorFilter.tint(color)), + ) { + content() + } + } +} diff --git a/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetHeader.kt b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetHeader.kt new file mode 100644 index 0000000..98df204 --- /dev/null +++ b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetHeader.kt @@ -0,0 +1,184 @@ +package de.jeanlucmakiola.floret.glance + +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.ColorFilter +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.action.Action +import androidx.glance.action.clickable +import androidx.glance.appwidget.cornerRadius +import androidx.glance.background +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.Row +import androidx.glance.layout.RowScope +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.height +import androidx.glance.layout.padding +import androidx.glance.layout.size +import androidx.glance.layout.width +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextStyle +import androidx.glance.unit.ColorProvider + +/** Visual weight of a [WidgetActionButton]. */ +enum class WidgetActionStyle { TONAL, FILLED } + +/** + * A widget's title row: the title (clickable to [titleAction], when given), + * weighted to fill, and a trailing slot for [WidgetActionButton]s. On a + * [WidgetBackground.TRANSPARENT] root the title gets its own small pill behind + * it, so it stays legible over an arbitrary wallpaper (calendula#292); on a + * filled root it sits directly on the surface, as before. + */ +@Composable +fun WidgetHeader( + title: String, + modifier: GlanceModifier = GlanceModifier, + titleAction: Action? = null, + actions: @Composable RowScope.() -> Unit = {}, +) { + val transparentTitle = LocalWidgetBackground.current == WidgetBackground.TRANSPARENT + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + val titleModifier = GlanceModifier.defaultWeight().let { + if (titleAction != null) it.clickable(titleAction) else it + } + if (transparentTitle) { + Box( + modifier = titleModifier + .cornerRadius(12.dp) + .background(GlanceTheme.colors.widgetBackground) + .padding(horizontal = 10.dp, vertical = 4.dp), + ) { + HeaderText(title) + } + } else { + Box(modifier = titleModifier) { + HeaderText(title) + } + } + actions() + } +} + +@Composable +private fun HeaderText(title: String) { + Text( + text = title, + maxLines = 1, + style = TextStyle( + color = GlanceTheme.colors.primary, + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + ), + ) +} + +/** + * The standard trailing action of a [WidgetHeader]: a filled, flat pill + * ([HeaderActionWidth] × [HeaderActionHeight]) that leads without crowding + * the rows below it. + */ +@Composable +fun WidgetHeaderAction( + icon: ImageProvider, + contentDescription: String, + action: Action, + modifier: GlanceModifier = GlanceModifier, +) { + WidgetActionButton( + icon = icon, + contentDescription = contentDescription, + action = action, + modifier = modifier, + style = WidgetActionStyle.FILLED, + size = HeaderActionWidth, + height = HeaderActionHeight, + ) +} + +private val HeaderActionWidth = 36.dp +private val HeaderActionHeight = 28.dp + +/** + * A widget's primary tappable action: a [size] × [height] target (a 48dp + * circle by default) in a full-pill shape, [WidgetActionStyle.TONAL] + * (`primaryContainer`) by default or [WidgetActionStyle.FILLED] (`primary`) + * for the one action a widget wants to lead with. With [label] it widens into + * an extended pill ("+ New task") for a widget wide enough to carry one; + * without it, it stays [size] wide. A header that only has room to spare + * above a short list can pass a shorter [height] than [size] — a flatter pill + * rather than a full circle — to stay out of the rows' way. + */ +@Composable +fun WidgetActionButton( + icon: ImageProvider, + contentDescription: String, + action: Action, + modifier: GlanceModifier = GlanceModifier, + style: WidgetActionStyle = WidgetActionStyle.TONAL, + label: String? = null, + size: Dp = 48.dp, + height: Dp = size, +) { + val container = when (style) { + WidgetActionStyle.TONAL -> GlanceTheme.colors.primaryContainer + WidgetActionStyle.FILLED -> GlanceTheme.colors.primary + } + val content = when (style) { + WidgetActionStyle.TONAL -> GlanceTheme.colors.onPrimaryContainer + WidgetActionStyle.FILLED -> GlanceTheme.colors.onPrimary + } + val iconSize = height * 0.65f + if (label == null) { + Box( + modifier = modifier + .width(size) + .height(height) + .cornerRadius(height / 2) + .background(container) + .clickable(action), + contentAlignment = Alignment.Center, + ) { + ActionIcon(icon, contentDescription, content, iconSize = iconSize) + } + } else { + Row( + modifier = modifier + .height(height) + .cornerRadius(height / 2) + .background(container) + .clickable(action) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ActionIcon(icon, contentDescription, content, iconSize = iconSize) + Spacer(GlanceModifier.width(8.dp)) + Text( + text = label, + maxLines = 1, + style = TextStyle(color = content, fontSize = 14.sp, fontWeight = FontWeight.Medium), + ) + } + } +} + +@Composable +private fun ActionIcon(icon: ImageProvider, contentDescription: String, tint: ColorProvider, iconSize: Dp) { + Image( + provider = icon, + contentDescription = contentDescription, + colorFilter = ColorFilter.tint(tint), + modifier = GlanceModifier.size(iconSize), + ) +} diff --git a/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetMessage.kt b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetMessage.kt new file mode 100644 index 0000000..04cc7e5 --- /dev/null +++ b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetMessage.kt @@ -0,0 +1,43 @@ +package de.jeanlucmakiola.floret.glance + +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.appwidget.cornerRadius +import androidx.glance.background +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.padding +import androidx.glance.text.Text +import androidx.glance.text.TextAlign +import androidx.glance.text.TextStyle + +/** + * A widget's empty or unavailable state: centred text in its own tonal + * container, so it reads on [WidgetBackground.TRANSPARENT] exactly as the + * populated rows do. + */ +@Composable +fun WidgetMessage(text: String, modifier: GlanceModifier = GlanceModifier) { + Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Box( + modifier = GlanceModifier + .cornerRadius(16.dp) + .background(GlanceTheme.colors.surfaceVariant) + .padding(16.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = text, + style = TextStyle( + color = GlanceTheme.colors.onSurfaceVariant, + fontSize = 14.sp, + textAlign = TextAlign.Center, + ), + ) + } + } +} diff --git a/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetProgressRing.kt b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetProgressRing.kt new file mode 100644 index 0000000..7b5749d --- /dev/null +++ b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetProgressRing.kt @@ -0,0 +1,79 @@ +package de.jeanlucmakiola.floret.glance + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.RectF +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.unit.Dp +import androidx.glance.GlanceModifier +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.LocalContext +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.size +import androidx.glance.unit.ColorProvider +import kotlin.math.min +import kotlin.math.roundToInt + +/** + * A determinate ring, since Glance 1.2's own `CircularProgressIndicator` is + * indeterminate only — drawn as a bitmap (round-capped arc over its track) + * rather than a Compose `Shape`, the only way to get a determinate ring into + * a `RemoteViews` tree. [progress] is clamped to `0f..1f`; [content] (a + * count or label) is centred over it. + */ +@Composable +fun WidgetProgressRing( + progress: Float, + size: Dp, + trackColor: ColorProvider, + indicatorColor: ColorProvider, + modifier: GlanceModifier = GlanceModifier, + strokeWidth: Dp = size * STROKE_FRACTION, + content: @Composable () -> Unit = {}, +) { + val context = LocalContext.current + val density = context.resources.displayMetrics.density + val pixels = min((size.value * density).roundToInt(), MAX_BITMAP_PX) + val strokePixels = strokeWidth.value * density + val track = trackColor.getColor(context).toArgb() + val indicator = indicatorColor.getColor(context).toArgb() + val bitmap = ringBitmap(pixels, strokePixels, progress.coerceIn(0f, 1f), track, indicator) + Box(modifier = modifier.size(size), contentAlignment = Alignment.Center) { + Image( + provider = ImageProvider(bitmap), + contentDescription = null, + modifier = GlanceModifier.size(size), + ) + content() + } +} + +private fun ringBitmap(pixels: Int, strokePx: Float, progress: Float, trackArgb: Int, indicatorArgb: Int): Bitmap { + val bitmap = Bitmap.createBitmap(pixels, pixels, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + val inset = strokePx / 2 + val bounds = RectF(inset, inset, pixels - inset, pixels - inset) + val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = strokePx + strokeCap = Paint.Cap.ROUND + } + paint.color = trackArgb + canvas.drawOval(bounds, paint) + if (progress > 0f) { + paint.color = indicatorArgb + canvas.drawArc(bounds, START_ANGLE, SWEEP_DEGREES * progress, false, paint) + } + return bitmap +} + +private const val STROKE_FRACTION = 0.1f +private const val START_ANGLE = -90f +private const val SWEEP_DEGREES = 360f + +/** Keeps the bitmap well under the RemoteViews binder transaction limit. */ +private const val MAX_BITMAP_PX = 512 diff --git a/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetRollover.kt b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetRollover.kt new file mode 100644 index 0000000..d05935d --- /dev/null +++ b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetRollover.kt @@ -0,0 +1,79 @@ +package de.jeanlucmakiola.floret.glance + +import android.app.AlarmManager +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Context +import androidx.core.content.getSystemService +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.plus +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Clock +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant + +/** + * A widget family's own wake-up for the next local midnight, so a "Today" / + * date-relative label flips over on the day boundary. `ACTION_DATE_CHANGED` + * isn't delivered to manifest receivers since Android 8, which is why this + * exists. One inexact alarm at a time, re-armed by every firing: + * `setAndAllowWhileIdle` needs no permission and survives doze, and a rollover + * a few minutes late is invisible on a sleeping screen. + * + * App-agnostic: [widgetReceiverClasses] names the receiver(s) whose placed + * instances decide whether the alarm is worth keeping armed, and + * [rolloverPendingIntent] builds the `PendingIntent` the app's own + * `FloretWidgetUpdateReceiver` subclass will receive it on. + */ +object WidgetRollover { + + /** Fire just after midnight, so an early delivery doesn't still read the old date. */ + val ROLLOVER_SLACK = 5.seconds + + private const val MAX_LOOKAHEAD_DAYS = 3 + + /** Arm the next rollover, or cancel it when no widget of [widgetReceiverClasses] is placed. Idempotent. */ + fun sync( + context: Context, + widgetReceiverClasses: List>, + rolloverPendingIntent: (Context) -> PendingIntent, + ) { + val appContext = context.applicationContext + val alarmManager = appContext.getSystemService() ?: return + val pendingIntent = rolloverPendingIntent(appContext) + if (!hasPlacedWidgets(appContext, widgetReceiverClasses)) { + alarmManager.cancel(pendingIntent) + return + } + val triggerAt = nextRolloverAt(Clock.System.now(), TimeZone.currentSystemDefault()) + alarmManager.setAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, triggerAt.toEpochMilliseconds(), pendingIntent, + ) + } + + /** + * The instant just after the next local midnight following [now] in [zone]. + * Uses the actual start of day, so it holds where DST skips midnight or a + * date-line move skips a whole date. + */ + fun nextRolloverAt(now: Instant, zone: TimeZone): Instant { + val date = now.toLocalDateTime(zone).date + var days = 1 + while (days <= MAX_LOOKAHEAD_DAYS) { + val candidate = date.plus(days, DateTimeUnit.DAY).atStartOfDayIn(zone) + ROLLOVER_SLACK + if (candidate > now) return candidate + days++ + } + // Unreachable for any real zone; an hour rather than the slack so it can't loop. + return now + 1.hours + } + + fun hasPlacedWidgets(context: Context, widgetReceiverClasses: List>): Boolean { + val manager = AppWidgetManager.getInstance(context) ?: return false + return widgetReceiverClasses.any { manager.getAppWidgetIds(ComponentName(context, it)).isNotEmpty() } + } +} diff --git a/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetShape.kt b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetShape.kt new file mode 100644 index 0000000..a42db0f --- /dev/null +++ b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetShape.kt @@ -0,0 +1,67 @@ +package de.jeanlucmakiola.floret.glance + +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.Dp +import androidx.glance.ColorFilter +import androidx.glance.GlanceModifier +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.action.Action +import androidx.glance.action.clickable +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.size +import androidx.glance.unit.ColorProvider + +/** + * M3 Expressive shapes for widgets. RemoteViews can't clip to an arbitrary + * outline, so each is a tinted vector drawn behind the content instead. + */ +enum class WidgetShape(internal val drawable: Int) { + COOKIE(R.drawable.floret_widget_shape_cookie), + SUNNY(R.drawable.floret_widget_shape_sunny), +} + +/** A [size] square filled with [shape] in [color], with [content] centred on top. */ +@Composable +fun WidgetShapedBox( + shape: WidgetShape, + color: ColorProvider, + size: Dp, + modifier: GlanceModifier = GlanceModifier, + onClick: Action? = null, + content: @Composable () -> Unit = {}, +) { + val clickable = if (onClick != null) GlanceModifier.clickable(onClick) else GlanceModifier + Box(modifier = modifier.size(size).then(clickable), contentAlignment = Alignment.Center) { + Image( + provider = ImageProvider(shape.drawable), + contentDescription = null, + colorFilter = ColorFilter.tint(color), + modifier = GlanceModifier.size(size), + ) + content() + } +} + +/** A [shape]-backed icon button — a widget's hero action, louder than a pill. */ +@Composable +fun WidgetShapedIconButton( + shape: WidgetShape, + icon: ImageProvider, + contentDescription: String, + action: Action, + color: ColorProvider, + iconTint: ColorProvider, + size: Dp, + modifier: GlanceModifier = GlanceModifier, +) { + WidgetShapedBox(shape = shape, color = color, size = size, modifier = modifier, onClick = action) { + Image( + provider = icon, + contentDescription = contentDescription, + colorFilter = ColorFilter.tint(iconTint), + modifier = GlanceModifier.size(size * 0.42f), + ) + } +} diff --git a/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetSurface.kt b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetSurface.kt new file mode 100644 index 0000000..ac82d14 --- /dev/null +++ b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetSurface.kt @@ -0,0 +1,60 @@ +package de.jeanlucmakiola.floret.glance + +import android.os.Build +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.unit.dp +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.appwidget.appWidgetBackground +import androidx.glance.appwidget.cornerRadius +import androidx.glance.background +import androidx.glance.layout.Box +import androidx.glance.layout.fillMaxSize + +/** + * How a widget's root fills in behind its content. [TINTED] is the softer tone + * requested for the M3 Expressive pass (calendula#90); [TRANSPARENT] draws no + * fill at all, so rows must stay in their own opaque [WidgetGroupedRow] + * container to keep contrast on an arbitrary wallpaper (calendula#292). + */ +enum class WidgetBackground { SOLID, TINTED, TRANSPARENT } + +/** The [WidgetBackground] the nearest [WidgetSurface] was given, for a child that adapts to it. */ +val LocalWidgetBackground = compositionLocalOf { WidgetBackground.SOLID } + +/** Falls back to this radius below API 31, where there's no system widget-corner dimension. */ +private val LegacyWidgetCornerRadius = 20.dp + +/** + * A widget's root container: fills the host, takes the system's rounded corner + * on API 31+ (a fixed fallback below that), and fills with [background]. + * `appWidgetBackground()` lets the launcher draw its own selection/press + * affordance behind the widget; it carries no colour of its own, so the fill is + * layered on top of it here. + */ +@Composable +fun WidgetSurface( + background: WidgetBackground, + modifier: GlanceModifier = GlanceModifier, + content: @Composable () -> Unit, +) { + val radius = modifier.appWidgetBackground().let { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + it.cornerRadius(android.R.dimen.system_app_widget_background_radius) + } else { + it.cornerRadius(LegacyWidgetCornerRadius) + } + } + val filled = when (background) { + WidgetBackground.SOLID -> radius.background(GlanceTheme.colors.widgetBackground) + WidgetBackground.TINTED -> radius.background(GlanceTheme.colors.secondaryContainer) + WidgetBackground.TRANSPARENT -> radius + } + CompositionLocalProvider(LocalWidgetBackground provides background) { + Box(modifier = filled.fillMaxSize()) { + content() + } + } +} diff --git a/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetTile.kt b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetTile.kt new file mode 100644 index 0000000..0dc9596 --- /dev/null +++ b/glance/src/main/kotlin/de/jeanlucmakiola/floret/glance/WidgetTile.kt @@ -0,0 +1,42 @@ +package de.jeanlucmakiola.floret.glance + +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import androidx.glance.GlanceModifier +import androidx.glance.action.Action +import androidx.glance.action.clickable +import androidx.glance.appwidget.cornerRadius +import androidx.glance.background +import androidx.glance.layout.Box +import androidx.glance.layout.fillMaxHeight +import androidx.glance.layout.padding +import androidx.glance.unit.ColorProvider + +/** + * A fully-rounded tonal block — the Lists widget's per-list tile and the Week + * widget's per-day column share this shape. Unlike [WidgetGroupedRow] it is + * always [de.jeanlucmakiola.floret.components.Position.Alone]-shaped (every + * tile stands on its own, with its own gap to its neighbours), so it uses a + * plain `cornerRadius` rather than the position-aware shape drawables. + */ +@Composable +fun WidgetTile( + color: ColorProvider, + modifier: GlanceModifier = GlanceModifier, + onClick: Action? = null, + content: @Composable () -> Unit, +) { + val clickable = if (onClick != null) GlanceModifier.clickable(onClick) else GlanceModifier + Box( + modifier = modifier + .fillMaxHeight() + .cornerRadius(TileRadius) + .background(color) + .then(clickable) + .padding(TilePadding), + content = content, + ) +} + +private val TileRadius = 16.dp +private val TilePadding = 8.dp diff --git a/glance/src/main/res/drawable/floret_ic_widget_add.xml b/glance/src/main/res/drawable/floret_ic_widget_add.xml new file mode 100644 index 0000000..4797a8b --- /dev/null +++ b/glance/src/main/res/drawable/floret_ic_widget_add.xml @@ -0,0 +1,6 @@ + + + diff --git a/glance/src/main/res/drawable/floret_ic_widget_check.xml b/glance/src/main/res/drawable/floret_ic_widget_check.xml new file mode 100644 index 0000000..509779c --- /dev/null +++ b/glance/src/main/res/drawable/floret_ic_widget_check.xml @@ -0,0 +1,10 @@ + + + + diff --git a/glance/src/main/res/drawable/floret_ic_widget_checklist.xml b/glance/src/main/res/drawable/floret_ic_widget_checklist.xml new file mode 100644 index 0000000..b99d82b --- /dev/null +++ b/glance/src/main/res/drawable/floret_ic_widget_checklist.xml @@ -0,0 +1,11 @@ + + + + diff --git a/glance/src/main/res/drawable/floret_widget_row_alone.xml b/glance/src/main/res/drawable/floret_widget_row_alone.xml new file mode 100644 index 0000000..f37f2c6 --- /dev/null +++ b/glance/src/main/res/drawable/floret_widget_row_alone.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/glance/src/main/res/drawable/floret_widget_row_bottom.xml b/glance/src/main/res/drawable/floret_widget_row_bottom.xml new file mode 100644 index 0000000..279fc2b --- /dev/null +++ b/glance/src/main/res/drawable/floret_widget_row_bottom.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/glance/src/main/res/drawable/floret_widget_row_middle.xml b/glance/src/main/res/drawable/floret_widget_row_middle.xml new file mode 100644 index 0000000..431302c --- /dev/null +++ b/glance/src/main/res/drawable/floret_widget_row_middle.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/glance/src/main/res/drawable/floret_widget_row_top.xml b/glance/src/main/res/drawable/floret_widget_row_top.xml new file mode 100644 index 0000000..e9687f8 --- /dev/null +++ b/glance/src/main/res/drawable/floret_widget_row_top.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/glance/src/main/res/drawable/floret_widget_shape_cookie.xml b/glance/src/main/res/drawable/floret_widget_shape_cookie.xml new file mode 100644 index 0000000..d297723 --- /dev/null +++ b/glance/src/main/res/drawable/floret_widget_shape_cookie.xml @@ -0,0 +1,7 @@ + + + + diff --git a/glance/src/main/res/drawable/floret_widget_shape_sunny.xml b/glance/src/main/res/drawable/floret_widget_shape_sunny.xml new file mode 100644 index 0000000..3388604 --- /dev/null +++ b/glance/src/main/res/drawable/floret_widget_shape_sunny.xml @@ -0,0 +1,7 @@ + + + + diff --git a/glance/src/test/kotlin/de/jeanlucmakiola/floret/glance/FloretWidgetUpdateReceiverTest.kt b/glance/src/test/kotlin/de/jeanlucmakiola/floret/glance/FloretWidgetUpdateReceiverTest.kt new file mode 100644 index 0000000..34874c3 --- /dev/null +++ b/glance/src/test/kotlin/de/jeanlucmakiola/floret/glance/FloretWidgetUpdateReceiverTest.kt @@ -0,0 +1,51 @@ +package de.jeanlucmakiola.floret.glance + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +/** Exercises the action-set wiring via reflection, since it's computed from `protected` members. */ +class FloretWidgetUpdateReceiverTest { + + private class Fake(override val extraHandledActions: Set = emptySet()) : FloretWidgetUpdateReceiver() { + override val rolloverAction = "app.widget.ROLLOVER" + override val widgetReceiverClasses: List> = emptyList() + override fun rolloverPendingIntent(context: Context): PendingIntent = error("not exercised") + override suspend fun redraw(context: Context) = Unit + } + + @Test + fun `the rollover action is handled and re-arms`() { + val receiver = Fake() + assertThat(receiver.handledActions).contains("app.widget.ROLLOVER") + assertThat(receiver.rearmActions).contains("app.widget.ROLLOVER") + } + + @Test + fun `a language change redraws without re-arming`() { + val receiver = Fake() + assertThat(receiver.handledActions).contains(Intent.ACTION_LOCALE_CHANGED) + assertThat(receiver.rearmActions).doesNotContain(Intent.ACTION_LOCALE_CHANGED) + } + + @Test + fun `everything that loses the alarm re-arms it`() { + assertThat(Fake().rearmActions).containsAtLeast( + "app.widget.ROLLOVER", + Intent.ACTION_BOOT_COMPLETED, + Intent.ACTION_MY_PACKAGE_REPLACED, + Intent.ACTION_TIME_CHANGED, + Intent.ACTION_TIMEZONE_CHANGED, + Intent.ACTION_DATE_CHANGED, + ) + } + + @Test + fun `an extra action is handled but never re-arms`() { + val receiver = Fake(extraHandledActions = setOf("app.PROVIDER_CHANGED")) + assertThat(receiver.handledActions).contains("app.PROVIDER_CHANGED") + assertThat(receiver.rearmActions).doesNotContain("app.PROVIDER_CHANGED") + } +} diff --git a/glance/src/test/kotlin/de/jeanlucmakiola/floret/glance/WidgetRolloverTest.kt b/glance/src/test/kotlin/de/jeanlucmakiola/floret/glance/WidgetRolloverTest.kt new file mode 100644 index 0000000..fffe7a2 --- /dev/null +++ b/glance/src/test/kotlin/de/jeanlucmakiola/floret/glance/WidgetRolloverTest.kt @@ -0,0 +1,145 @@ +package de.jeanlucmakiola.floret.glance + +import com.google.common.truth.Truth.assertThat +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime +import org.junit.jupiter.api.Test +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Instant + +/** The "when is the next local midnight" arithmetic, especially where midnight is not 00:00. */ +class WidgetRolloverTest { + + private val berlin = TimeZone.of("Europe/Berlin") + + private fun at(local: String, zone: TimeZone): Instant = + LocalDateTime.parse(local).toInstant(zone) + + private fun nextRollover(local: String, zone: TimeZone = berlin): Instant = + WidgetRollover.nextRolloverAt(at(local, zone), zone) + + // --- the ordinary day ---------------------------------------------------- + + @Test + fun `midday rolls over at the coming midnight`() { + val next = nextRollover("2026-08-27T12:00:00") + assertThat(next).isEqualTo(at("2026-08-28T00:00:05", berlin)) + } + + @Test + fun `a second before midnight still targets tonight, not tomorrow night`() { + val next = nextRollover("2026-08-27T23:59:59") + assertThat(next).isEqualTo(at("2026-08-28T00:00:05", berlin)) + } + + @Test + fun `at midnight exactly the target is the next day, never the current instant`() { + // Re-arming after a firing must move a whole day on, or the widget wakes + // itself in a tight loop. + val now = at("2026-08-28T00:00:00", berlin) + val next = WidgetRollover.nextRolloverAt(now, berlin) + assertThat(next).isEqualTo(at("2026-08-29T00:00:05", berlin)) + assertThat(next - now).isGreaterThan(Duration.ZERO) + } + + @Test + fun `re-arming from the slack instant itself moves a full day on`() { + // What actually happens in practice: the receiver runs at midnight + slack. + val now = at("2026-08-28T00:00:05", berlin) + assertThat(WidgetRollover.nextRolloverAt(now, berlin)) + .isEqualTo(at("2026-08-29T00:00:05", berlin)) + } + + @Test + fun `the result is always in the future for every minute of a day`() { + val zone = berlin + // Spans Berlin's 2024 spring-forward: the likeliest source of a target + // in the past, i.e. an alarm that fires immediately, forever. + var probe = LocalDateTime.parse("2024-03-29T00:00:00").toInstant(zone) + val end = LocalDateTime.parse("2024-04-01T00:00:00").toInstant(zone) + while (probe < end) { + assertThat(WidgetRollover.nextRolloverAt(probe, zone)).isGreaterThan(probe) + probe += 1.minutes + } + } + + // --- daylight saving ----------------------------------------------------- + + @Test + fun `spring forward keeps the rollover one day away, not one hour short`() { + // Berlin skipped 02:00-03:00 on 31 March 2024, so that day was 23h long. + // A rollover computed as "now + 24h" would land at 01:00 on 1 April. + val now = at("2024-03-30T12:00:00", berlin) + val next = WidgetRollover.nextRolloverAt(now, berlin) + assertThat(next).isEqualTo(at("2024-03-31T00:00:05", berlin)) + assertThat(next - now).isLessThan(24.hours) + } + + @Test + fun `fall back does not overshoot into the repeated hour`() { + // Berlin repeated 02:00-03:00 on 27 October 2024: a 25h day, so + // "now + 24h" would land at 23:00 on the 26th and never roll over. + val now = at("2024-10-26T12:00:00", berlin) + val next = WidgetRollover.nextRolloverAt(now, berlin) + assertThat(next).isEqualTo(at("2024-10-27T00:00:05", berlin)) + assertThat(next.toLocalDateTime(berlin).date).isEqualTo(LocalDate.parse("2024-10-27")) + } + + @Test + fun `a zone that repeats midnight takes the first start of day`() { + // Sao Paulo used to end DST by moving 00:00 back to 23:00, so the day + // began twice. Pinned as the accepted trade: the widget runs an hour + // ahead until the next redraw. No live zone does this since 2019. + val saoPaulo = TimeZone.of("America/Sao_Paulo") + val next = WidgetRollover.nextRolloverAt(at("2018-02-16T12:00:00", saoPaulo), saoPaulo) + val local = next.toLocalDateTime(saoPaulo) + assertThat(local.date).isEqualTo(LocalDate.parse("2018-02-17")) + assertThat(local.hour).isEqualTo(0) + } + + @Test + fun `a zone where midnight does not exist rolls over at the real start of day`() { + // Cuba starts DST at 00:00, so 11 March 2018 began at 01:00 in Havana. + // Targeting a literal 00:00 there would arm an instant on the wrong day. + val havana = TimeZone.of("America/Havana") + val next = WidgetRollover.nextRolloverAt(at("2018-03-10T12:00:00", havana), havana) + val local = next.toLocalDateTime(havana) + assertThat(local.date).isEqualTo(LocalDate.parse("2018-03-11")) + assertThat(local.hour).isEqualTo(1) + assertThat(local.minute).isEqualTo(0) + // And it is genuinely the first instant of that date, not a guess. + assertThat(next).isEqualTo( + LocalDate.parse("2018-03-11").atStartOfDayIn(havana) + WidgetRollover.ROLLOVER_SLACK, + ) + } + + // --- timezone changes ---------------------------------------------------- + + @Test + fun `the same instant rolls over at different times in different zones`() { + // TIMEZONE_CHANGED must re-arm to the new local midnight: the arithmetic + // follows the zone, not a cached offset. + val instant = at("2026-08-27T12:00:00", berlin) + val tokyo = TimeZone.of("Asia/Tokyo") + val berlinNext = WidgetRollover.nextRolloverAt(instant, berlin) + val tokyoNext = WidgetRollover.nextRolloverAt(instant, tokyo) + assertThat(tokyoNext).isNotEqualTo(berlinNext) + assertThat(tokyoNext).isLessThan(berlinNext) + assertThat(tokyoNext.toLocalDateTime(tokyo).hour).isEqualTo(0) + } + + @Test + fun `a half-hour offset zone still lands on its own midnight`() { + val kathmandu = TimeZone.of("Asia/Kathmandu") + val next = WidgetRollover.nextRolloverAt(at("2026-08-27T12:00:00", kathmandu), kathmandu) + val local = next.toLocalDateTime(kathmandu) + assertThat(local.date.toString()).isEqualTo("2026-08-28") + assertThat(local.hour).isEqualTo(0) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 87ea3d6..f9f5d59 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,16 +1,24 @@ # Version catalog for floret-kit. Kept in lockstep with the consuming apps' # catalogs (same AGP/Kotlin/Compose + test stack) until the catalog itself is shared. [versions] -agp = "9.2.1" -kotlin = "2.3.21" -coreKtx = "1.19.0" -appcompat = "1.7.1" +agp = "9.4.1" +kotlin = "2.4.20" +coreKtx = "1.19.1" +appcompat = "1.8.0" activityCompose = "1.13.0" -composeBom = "2026.05.01" -# Material 3 Expressive APIs live in the 1.5 alpha line; pinned to override the BOM. -material3 = "1.5.0-alpha21" -junit = "6.1.0" -junitPlatform = "6.1.0" +composeBom = "2026.09.00" +# Material 3 Expressive APIs live in the 1.5 alpha line (no stable 1.5 yet); pinned to override the BOM. +material3 = "1.5.0-alpha29" +junit = "6.1.3" +junitPlatform = "6.1.3" +ksp = "2.3.9" +hilt = "2.59.2" +datastore = "1.2.1" +kotlinxCoroutines = "1.10.2" +turbine = "1.2.0" +# Glance: kept in lockstep with the apps' widget stack. +glance = "1.2.0" +kotlinxDatetime = "0.7.0" truth = "1.4.5" [libraries] @@ -23,6 +31,16 @@ androidx-foundation = { group = "androidx.compose.foundation", name = "foundatio androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" } androidx-animation = { group = "androidx.compose.animation", name = "animation" } androidx-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" } +androidx-glance-appwidget = { group = "androidx.glance", name = "glance-appwidget", version.ref = "glance" } +androidx-glance-material3 = { group = "androidx.glance", name = "glance-material3", version.ref = "glance" } +kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version.ref = "kotlinxDatetime" } + +androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } +kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } +turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" } +hilt-core = { group = "com.google.dagger", name = "hilt-core", version.ref = "hilt" } +hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" } junit-jupiter-api = { group = "org.junit.jupiter", name = "junit-jupiter-api", version.ref = "junit" } junit-jupiter-engine = { group = "org.junit.jupiter", name = "junit-jupiter-engine", version.ref = "junit" } @@ -33,3 +51,4 @@ truth = { group = "com.google.truth", name = "truth", version.ref = "truth" } android-library = { id = "com.android.library", version.ref = "agp" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 1b33c55..5097068 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index b21b69e..1eca32e 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,8 +1,10 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip -distributionSha256Sum=bafc141b619ad6350fd975fc903156dd5c151998cc8b058e8c1044ab5f7b031f +distributionSha256Sum=bafd5ce9cfaea0fbccfdc8439a1ac42fbd4cd9c89dc9a988228d8a2639a58e6c +distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip networkTimeout=10000 +retries=0 +retryBackOffMs=500 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 23d15a9..249efbb 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ #!/bin/sh # -# Copyright © 2015-2021 the original authors. +# Copyright © 2015 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -20,7 +20,7 @@ ############################################################################## # -# Gradle start up script for POSIX generated by Gradle. +# gradlew start up script for POSIX generated by Gradle. # # Important for running: # @@ -29,7 +29,7 @@ # bash, then to run this script, type that shell name before the whole # command line, like: # -# ksh Gradle +# ksh gradlew # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: @@ -57,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -114,7 +114,6 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH="\\\"\\\"" # Determine the Java command to use to start the JVM. @@ -172,7 +171,6 @@ fi # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -212,7 +210,6 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" diff --git a/gradlew.bat b/gradlew.bat index db3a6ac..3185a43 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -19,12 +19,39 @@ @if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem -@rem Gradle startup script for Windows +@rem gradlew startup script for Windows @rem @rem ########################################################################## -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +@rem Catch executions from older scripts and ensure they exit cleanly. +@rem This can be removed once we can be reasonably confident that few people +@rem will be migrating directly to this new wrapper. +goto afterSafetyNet +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +goto exitWithErrorLevel +:afterSafetyNet set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @@ -45,13 +72,14 @@ set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. -goto fail +"%COMSPEC%" /c exit 1 +goto exitWithErrorLevel :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -59,36 +87,26 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. -goto fail +"%COMSPEC%" /c exit 1 +goto exitWithErrorLevel :execute @rem Setup the command line -set CLASSPATH= -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +@rem This label must not be changed. We rely on old scripts being able to jump to this point. +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/settings.gradle.kts b/settings.gradle.kts index 2a42f6c..ba063be 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -35,5 +35,8 @@ include(":core-time") include(":core-reminders") include(":core-locale") include(":core-crash") +include(":core-prefs") +include(":core-di") include(":identity") include(":components") +include(":glance")