From 3b9b0e2bba14322b45607273f1f7164869771b33 Mon Sep 17 00:00:00 2001 From: Luke Wass Date: Thu, 2 Apr 2026 08:49:56 -0500 Subject: [PATCH 1/2] fix: inline widget scaffold to prevent Glance layout ID collision Sharing WidgetRoot across GlanceAppWidget subclasses caused Glance to assign the same layout resource ID to both widgets, making the temp widget display alarm content after the alarm widget was placed. Each widget now owns its full Box > Box scaffold so their composable trees are unique. WidgetRoot.kt deleted. Docs updated with the constraint. --- .../wassupluke/widgets/widget/AlarmWidget.kt | 40 +++++++++++-------- .../widgets/widget/WeatherWidget.kt | 25 ++++++++---- .../wassupluke/widgets/widget/WidgetRoot.kt | 25 ------------ docs/adding-a-widget.md | 12 +++++- 4 files changed, 52 insertions(+), 50 deletions(-) delete mode 100644 app/src/main/java/com/wassupluke/widgets/widget/WidgetRoot.kt diff --git a/app/src/main/java/com/wassupluke/widgets/widget/AlarmWidget.kt b/app/src/main/java/com/wassupluke/widgets/widget/AlarmWidget.kt index 7e6a3ac..e47f508 100644 --- a/app/src/main/java/com/wassupluke/widgets/widget/AlarmWidget.kt +++ b/app/src/main/java/com/wassupluke/widgets/widget/AlarmWidget.kt @@ -12,10 +12,13 @@ import androidx.compose.ui.unit.dp import androidx.glance.* import androidx.glance.action.Action import androidx.glance.action.actionStartActivity +import androidx.glance.action.clickable import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.provideContent import androidx.glance.layout.Alignment +import androidx.glance.layout.Box import androidx.glance.layout.Row +import androidx.glance.layout.fillMaxSize import androidx.glance.layout.Spacer import androidx.glance.layout.size import androidx.glance.layout.width @@ -76,23 +79,28 @@ private fun AlarmWidgetContent( tapAction: Action, fontSize: Int ) { - WidgetRoot(tapAction = tapAction) { - Row(verticalAlignment = Alignment.CenterVertically) { - Image( - provider = ImageProvider(R.drawable.ic_alarm), - contentDescription = null, - modifier = GlanceModifier.size(fontSize.dp), - colorFilter = ColorFilter.tint(textColorProvider) - ) - Spacer(GlanceModifier.width(4.dp)) - Text( - text = alarmText, - style = TextStyle( - fontSize = fontSize.sp, - fontWeight = FontWeight.Normal, - color = textColorProvider + Box( + modifier = GlanceModifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Box(modifier = GlanceModifier.clickable(tapAction)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Image( + provider = ImageProvider(R.drawable.ic_alarm), + contentDescription = null, + modifier = GlanceModifier.size(fontSize.dp), + colorFilter = ColorFilter.tint(textColorProvider) ) - ) + Spacer(GlanceModifier.width(4.dp)) + Text( + text = alarmText, + style = TextStyle( + fontSize = fontSize.sp, + fontWeight = FontWeight.Normal, + color = textColorProvider + ) + ) + } } } } diff --git a/app/src/main/java/com/wassupluke/widgets/widget/WeatherWidget.kt b/app/src/main/java/com/wassupluke/widgets/widget/WeatherWidget.kt index 595515d..19f4f01 100644 --- a/app/src/main/java/com/wassupluke/widgets/widget/WeatherWidget.kt +++ b/app/src/main/java/com/wassupluke/widgets/widget/WeatherWidget.kt @@ -11,9 +11,13 @@ import androidx.datastore.preferences.core.emptyPreferences import androidx.glance.* import androidx.glance.action.Action import androidx.glance.action.actionStartActivity +import androidx.glance.action.clickable import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.provideContent import androidx.glance.GlanceTheme +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.fillMaxSize import androidx.glance.text.* import androidx.glance.unit.ColorProvider import com.wassupluke.widgets.data.WeatherDataStore @@ -82,14 +86,19 @@ private fun WeatherWidgetContent( tapAction: Action, fontSize: Int ) { - WidgetRoot(tapAction = tapAction) { - Text( - text = displayTemp, - style = TextStyle( - fontSize = fontSize.sp, - fontWeight = FontWeight.Normal, - color = textColorProvider + Box( + modifier = GlanceModifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Box(modifier = GlanceModifier.clickable(tapAction)) { + Text( + text = displayTemp, + style = TextStyle( + fontSize = fontSize.sp, + fontWeight = FontWeight.Normal, + color = textColorProvider + ) ) - ) + } } } diff --git a/app/src/main/java/com/wassupluke/widgets/widget/WidgetRoot.kt b/app/src/main/java/com/wassupluke/widgets/widget/WidgetRoot.kt deleted file mode 100644 index e3825e5..0000000 --- a/app/src/main/java/com/wassupluke/widgets/widget/WidgetRoot.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.wassupluke.widgets.widget - -import androidx.compose.runtime.Composable -import androidx.glance.GlanceModifier -import androidx.glance.action.Action -import androidx.glance.action.clickable -import androidx.glance.layout.Alignment -import androidx.glance.layout.Box -import androidx.glance.layout.fillMaxSize - -/** - * Standard widget scaffold: centers content and bounds the tap target to the content area, - * so empty widget surface space does not intercept touches. - */ -@Composable -fun WidgetRoot(tapAction: Action, content: @Composable () -> Unit) { - Box( - modifier = GlanceModifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Box(modifier = GlanceModifier.clickable(tapAction)) { - content() - } - } -} diff --git a/docs/adding-a-widget.md b/docs/adding-a-widget.md index f5f832a..d0ffb7b 100644 --- a/docs/adding-a-widget.md +++ b/docs/adding-a-widget.md @@ -40,7 +40,17 @@ Shared strings (`settings_tap_none_label`, `settings_tap_app_missing_label`, app ### `Widget.kt` -Mirror `WeatherWidget.kt` or `AlarmWidget.kt`. All widgets share these DataStore keys: +Mirror `WeatherWidget.kt` or `AlarmWidget.kt`. **Do not extract a shared root composable** (e.g. a `WidgetRoot` helper) that is reused across multiple `GlanceAppWidget` subclasses — Glance can assign the same layout resource ID to widgets that share composable functions, causing one widget's content to appear on another. Each widget's `Content` composable must own its full scaffold: + +```kotlin +Box(modifier = GlanceModifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Box(modifier = GlanceModifier.clickable(tapAction)) { + // widget-specific content here + } +} +``` + +All widgets share these DataStore keys: - `WIDGET_TEXT_COLOR` + `WIDGET_DYNAMIC_COLOR` + `resolveDynamicColor()` — color - `FONT_SIZE` / `DEFAULT_FONT_SIZE` — font size - Widget-specific tap key: `_WIDGET_TAP_PACKAGE` From c31a892dea7e3fd166d5c007b6e7d2e8332d545a Mon Sep 17 00:00:00 2001 From: Luke Wass Date: Thu, 2 Apr 2026 08:56:13 -0500 Subject: [PATCH 2/2] docs: rewrite CLAUDE.md with accurate package, versions, and widget list Updates package from com.wassupluke.simpleweather to com.wassupluke.widgets, adds AlarmWidget/AlarmWidgetReceiver to the package structure, refreshes all dependency versions, and documents the Glance layout isolation constraint. --- CLAUDE.md | 117 ++++++++++++++++++++++++++++-------------------------- 1 file changed, 60 insertions(+), 57 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f82f2f2..64e2b51 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,94 +1,97 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +This file orients a new Claude Code session for this repository. ## Project Overview -A minimal Android weather app (Kotlin + Jetpack Compose + Material3) whose primary value is a home screen widget displaying the current temperature. Open-Meteo is used for weather and geocoding (no API key required). +Android home-screen widget app (Kotlin + Jetpack Compose + Glance) showing current temperature and next alarm. Open-Meteo provides weather and geocoding (no API key required). No Hilt, no Room. ## Architecture -**Stack:** Kotlin 2.0.21, Compose BOM 2024.12.01, Glance 1.1.1, WorkManager 2.10, DataStore 1.1, Retrofit 2.11 + kotlinx.serialization, FusedLocationProviderClient. Min SDK 26 / Target SDK 35. No Hilt, no Room. - **Data flow:** ``` -WorkManager (periodic) ──→ WeatherRepository ──→ Open-Meteo API - ↓ - DataStore (cache last reading) - ↓ - SettingsViewModel (UI) - WeatherWidget (reads DataStore directly via .first()) +WorkManager (periodic, CONNECTED) ──→ WeatherRepository ──→ Open-Meteo API + ↓ + DataStore ("weather_settings") + ↓ + SettingsViewModel (UI) / WeatherWidget (reads via .first()) ``` **Key design decisions:** -- `WeatherRepository` takes two separate `OpenMeteoService` instances: `weatherService` (api.open-meteo.com) and `geocodingService` (geocoding-api.open-meteo.com) — both created in `NetworkModule` -- Retrofit does not support Kotlin default parameter values — all `@Query` params must be passed explicitly at every call site -- Temperature is always stored as Celsius (`LAST_TEMP_CELSIUS`); F/C conversion happens at display time only -- `WorkScheduler` uses `enqueueUniquePeriodicWork` with `ExistingPeriodicWorkPolicy.UPDATE` and requires `CONNECTED` network -- `WeatherFetchWorker` calls `WeatherWidget().updateAll(context)` after a successful fetch -- `SettingsViewModel` accepts a `CoroutineDispatcher` parameter for testability (inject `testScheduler` in tests, default `Dispatchers.IO` in production) -- `SettingsScreen` accepts `onLocationPermissionGranted: (() -> Unit)?` — do not cast `LocalContext` to `MainActivity`; pass the callback from the call site -- `MainActivity.onCreate` uses `lifecycleScope.launch(Dispatchers.IO)` for the WorkManager init block — never use a bare `CoroutineScope` here (leaks on Activity recreation) -- `WorkScheduler.schedule` is only called when `LOCATION_LAT` and `LOCATION_LON` are both present in DataStore — guard this in any new call sites -- `WeatherWidget` is wrapped in `GlanceTheme`; uses `GlanceTheme.colors.primary` for temp text when dynamic color is on, else a static `ColorProvider` -- `Preferences.resolveDynamicColor()` extension in `WeatherDataStore.kt` — returns `false` on pre-API 31 regardless of stored value -- New DataStore keys: `WIDGET_TAP_PACKAGE` (string) — package name of app to launch on widget tap; `WIDGET_DYNAMIC_COLOR` (boolean) -- Widget tap uses a `startActivity` `Action`; empty/null `WIDGET_TAP_PACKAGE` means no tap action + +1. `WeatherRepository` receives two separate `OpenMeteoService` instances: `weatherService` (api.open-meteo.com) and `geocodingService` (geocoding-api.open-meteo.com) — both wired in `NetworkModule`. +2. Retrofit does not honour Kotlin default parameter values — all `@Query` params must be passed explicitly at every call site. +3. Temperature is always stored as Celsius (`LAST_TEMP_CELSIUS`); F/C conversion happens at display time only. +4. `WorkScheduler.schedule` is guarded: only called when both `LOCATION_LAT` and `LOCATION_LON` are present in DataStore. Uses `ExistingPeriodicWorkPolicy.UPDATE` with a `CONNECTED` network constraint. +5. `WeatherFetchWorker` calls `WeatherWidget().updateAll(context)` after a successful fetch. +6. `SettingsViewModel` accepts a `CoroutineDispatcher` for testability (default `Dispatchers.IO`; inject `StandardTestDispatcher` in tests). +7. `MainActivity.onCreate` launches WorkManager init with `lifecycleScope.launch(Dispatchers.IO)` — never use a bare `CoroutineScope` (leaks on Activity recreation). +8. Both widgets are wrapped in `GlanceTheme`. When dynamic color is on, use `GlanceTheme.colors.primary` for text; otherwise use a static `ColorProvider`. `Preferences.resolveDynamicColor()` in `WeatherDataStore.kt` always returns `false` on pre-API 31. +9. Widget tap uses a `startActivity` Action. An empty/null `WIDGET_TAP_PACKAGE` (or `ALARM_WIDGET_TAP_PACKAGE`) falls back to launching `MainActivity`. +10. **Glance layout isolation (critical):** Never share a `@Composable` root scaffold across multiple `GlanceAppWidget` subclasses. Glance can assign the same layout resource ID to different widgets, causing content bleed. Each widget's Content composable must own its full `Box(fillMaxSize) > Box(clickable)` scaffold independently. +11. `AlarmWidgetReceiver` calls `goAsync()` for all broadcasts **except** `ACTION_APPWIDGET_UPDATE` (Glance's base class already calls `goAsync` internally for that action). ## Build & Test Commands Run all commands from the repo root. -```bash -# Build -./gradlew assembleDebug +| Command | Purpose | +|-|-| +| `./gradlew assembleDebug` | Full debug build | +| `./gradlew :app:compileDebugKotlin` | Compile-check only (faster) | +| `./gradlew :app:testDebugUnitTest` | All unit tests | +| `./gradlew :app:testDebugUnitTest --tests "com.wassupluke.widgets.data.WeatherRepositoryTest"` | Single test class | -# Compile-check only (faster) -./gradlew :app:compileDebugKotlin - -# Run all unit tests -./gradlew :app:testDebugUnitTest - -# Run a specific test class -./gradlew :app:testDebugUnitTest --tests "com.wassupluke.simpleweather.data.WeatherRepositoryTest" -``` - -Note: `./gradlew :app:test` is not supported — use `:app:testDebugUnitTest`. All unit tests use Robolectric (no emulator needed). +`./gradlew :app:test` is not supported. All unit tests use Robolectric — no emulator needed. ## Package Structure ``` -com.wassupluke.simpleweather +com.wassupluke.widgets ├── data/ -│ ├── WeatherDataStore.kt # DataStore keys + Context.dataStore extension; resolveDynamicColor() -│ ├── WeatherRepository.kt # fetchAndCacheWeather(), geocodeLocation() +│ ├── WeatherDataStore.kt # DataStore keys + Context.dataStore extension; resolveDynamicColor(); parseColorSafe() +│ ├── WeatherRepository.kt # fetchAndCacheWeather(), reverseGeocodeLocation(), geocodeLocation(); companion .create() │ └── api/ -│ ├── WeatherApiModels.kt # @Serializable data classes -│ ├── OpenMeteoService.kt # Retrofit interface (no default param values) -│ └── NetworkModule.kt # Two Retrofit instances (weather + geocoding) +│ ├── WeatherApiModels.kt # @Serializable data classes +│ ├── OpenMeteoService.kt # Retrofit interface (no default param values) +│ └── NetworkModule.kt # Two Retrofit+OkHttp instances (weather + geocoding) ├── ui/ -│ ├── MainActivity.kt # Manual ViewModelProvider.Factory; fetchAndSaveDeviceLocation() +│ ├── MainActivity.kt # Manual ViewModelProvider.Factory; WorkManager init on Dispatchers.IO; fetchAndSaveDeviceLocation() │ ├── theme/SimpleWeatherTheme.kt │ └── settings/ -│ ├── SettingsViewModel.kt # StateFlow from DataStore -│ └── SettingsScreen.kt # Compose UI: location toggle, F/C selector, interval picker, dynamic color toggle, app picker +│ ├── SettingsViewModel.kt # StateFlow from DataStore; dispatcher injection +│ └── SettingsScreen.kt # App picker; color/font/tap controls for both widgets ├── widget/ -│ ├── WeatherWidget.kt # GlanceAppWidget wrapped in GlanceTheme; tap-to-launch; dynamic color -│ └── WeatherWidgetReceiver.kt # GlanceAppWidgetReceiver +│ ├── WeatherWidget.kt # GlanceAppWidget; temp display; dynamic color; tap-to-launch +│ ├── WeatherWidgetReceiver.kt # GlanceAppWidgetReceiver stub +│ ├── AlarmWidget.kt # GlanceAppWidget; next alarm display; icon + text layout +│ └── AlarmWidgetReceiver.kt # GlanceAppWidgetReceiver; handles alarm/time/boot broadcasts; goAsync() └── worker/ - ├── WeatherFetchWorker.kt # CoroutineWorker; Result.failure() if no location - └── WorkScheduler.kt # schedule() / cancel() helpers + ├── WeatherFetchWorker.kt # CoroutineWorker; calls WeatherWidget().updateAll() on success + └── WorkScheduler.kt # enqueueUniquePeriodicWork UPDATE policy; CONNECTED constraint ``` +**DataStore keys (store name: `weather_settings`):** `USE_DEVICE_LOCATION`, `LOCATION_LAT`, `LOCATION_LON`, `LOCATION_DISPLAY_NAME`, `LOCATION_QUERY`, `TEMP_UNIT` (default `"F"`), `UPDATE_INTERVAL_MINUTES` (default `60`), `LAST_TEMP_CELSIUS`, `LAST_UPDATED_EPOCH`, `WIDGET_TEXT_COLOR`, `WIDGET_TAP_PACKAGE`, `WIDGET_DYNAMIC_COLOR`, `FONT_SIZE` (default `48`), `ALARM_WIDGET_TAP_PACKAGE`, `ALARM_TEXT`. + ## Testing Conventions -- All tests use `@RunWith(RobolectricTestRunner::class)` + `ApplicationProvider.getApplicationContext()` -- Each test class touching DataStore needs `@Before fun clearDataStore()` calling `context.dataStore.edit { it.clear() }` to prevent cross-test pollution -- Mock `OpenMeteoService` with MockK — pass separate mocks as `weatherService` and `geocodingService` -- ViewModel tests: inject `StandardTestDispatcher(testScheduler)` and call `advanceUntilIdle()` instead of `Thread.sleep` -- `SettingsViewModel.uiState` uses `stateIn(WhileSubscribed(5000))` — activate upstream with `backgroundScope.launch { vm.uiState.collect {} }` before asserting -- Assert ViewModel state via `vm.uiState.filter { ... }.first()`, not by reading DataStore directly +- `@RunWith(RobolectricTestRunner::class)` + `ApplicationProvider.getApplicationContext()` on all test classes. +- `@Before` must call `context.dataStore.edit { it.clear() }` to prevent cross-test DataStore pollution. +- Mock `OpenMeteoService` with MockK; pass separate mocks as `weatherService` and `geocodingService`. +- ViewModel tests: inject `StandardTestDispatcher(testScheduler)` and call `advanceUntilIdle()`. +- `SettingsViewModel.uiState` uses `stateIn(WhileSubscribed(5000))` — activate upstream before asserting: `backgroundScope.launch { vm.uiState.collect {} }`. +- Assert ViewModel state via `vm.uiState.filter { ... }.first()`, not by reading DataStore directly. ## Dependency Versions -All versions are in `gradle/libs.versions.toml`. Aliases follow the pattern `libs.compose.bom`, `libs.glance.appwidget`, `libs.work.runtime.ktx`, etc. +| Library | Version | +|-|-| +| Kotlin | 2.3.20 | +| Compose BOM | 2026.03.00 | +| Glance | 1.2.0-rc01 | +| WorkManager | 2.11.1 | +| DataStore | 1.2.1 | +| Retrofit | 3.0.0 | +| Min SDK / Target SDK | 26 / 36 | + +All version aliases live in `gradle/libs.versions.toml`.