diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 3d09ed1..df28cad 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -35,7 +35,7 @@ reviews: between native and Dart (HomeWidgetPreferences) must stay in sync. - path: "**/*_test.dart" instructions: >- - Ensure tests assert real behavior; home_widget and SharedPreferences + Ensure tests assert real behavior; WidgetStore and SharedPreferences mocks should follow the existing patterns in test/. chat: diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 4243588..02ddb9a 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -23,11 +23,6 @@ updates: commit-message: prefix: "chore" include: "scope" - # Ignore home_widget updates (intentionally pinned) - ignore: - - dependency-name: "home_widget" - # Ignore all versions - we're pinned to 0.8.0 for functional reasons - # See docs/HOME_WIDGET_VERSION_ISSUE.md # Group all minor/patch updates into a single PR groups: dev-dependencies: diff --git a/CLAUDE.md b/CLAUDE.md index 747751a..723e91c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,6 +18,7 @@ This file provides context for AI assistants working on this project. | Aspect | Value | |--------|-------| | Framework | Flutter 3.44.0 (pinned — see "Before Coding") | +| Android SDK | minSdk 30 (Android 11), target 36 — do NOT lower minSdk, see Gotcha #9 | | Weather API | Open-Meteo (free, no key) | | Charting | Native SVG (SvgChartGenerator.kt + AndroidSVG) | | Widget package | Native AppWidgetProvider + method-channel KV store (`widget_store.dart`) | @@ -47,7 +48,7 @@ lib/ │ ├── app_*.arb # Other languages │ └── app_localizations.dart # Generated ├── services/ -│ ├── location_service.dart # Geolocator wrapper with fallback +│ ├── location_service.dart # Native location (LocationBridge) with fallback │ ├── widget_service.dart # Triggers native widget refresh + resize flag │ ├── widget_store.dart # Method-channel KV bridge to HomeWidgetPreferences (replaces home_widget) │ └── native_svg_service.dart # Method channel to native (weather fetch, SVG gen, cache) @@ -280,3 +281,4 @@ adb logcat | grep -i "Error inflating" 6. **Implicit broadcasts** - Android 8.0+ requires runtime receiver registration (not manifest) 7. **Event staleness** - Widget checks `last_weather_update` timestamp (15 min threshold) 8. **Edge-to-edge warning** - Play Console may warn about deprecated APIs (setStatusBarColor etc.) - this is Flutter engine code, not app code; tracked in flutter/flutter#160328 +9. **minSdk is pinned to 30 (`app/build.gradle.kts`), NOT Flutter's default 24** - hard floor is 29: the widget's `WidgetTheme` parent `android:Theme.DeviceDefault.DayNight` requires API 29; on API 24-28 the launcher can't inflate the widget (blank/broken widget → Google Play "Broken Functionality" rejection, fixed 2026-06). 30 also gives `LocationListener` default callbacks (so `LocationProvider` needs no `onStatusChanged`/`onProviderEnabled`/`onProviderDisabled` stubs). Run `cd android && ./gradlew :app:lintDebug` and check for `NewApi` errors before shipping any resource/theme change. If you must support <29, give `WidgetTheme` an API-24-safe parent and add a `values-v29/styles.xml` DayNight override instead of lowering minSdk blindly; below API 30, restore the `LocationListener` stubs. diff --git a/README.md b/README.md index 29ae9b1..1623193 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ lib/ │ └── home_screen.dart # Main app screen (both chart panels) ├── services/ │ ├── location_service.dart # GPS + city search + reverse geocoding -│ ├── widget_service.dart # Home widget updates via home_widget +│ ├── widget_service.dart # Triggers native widget refresh (WidgetStore) │ ├── native_svg_service.dart # Method channel to Kotlin (SVG / weather / cache) │ ├── units_service.dart # Temperature unit and 12/24h logic │ └── material_you_service.dart # Material You color pass-through @@ -184,7 +184,7 @@ back in their preferred language. ### Android Widget The home screen widgets use: -- `HomeWidgetProvider` from the home_widget package +- `AppWidgetProvider` (native; two providers — 48h and 7-day weekly) - `RemoteViews` for native Android widget rendering - SVG chart generated in Kotlin (`SvgChartGenerator.kt`) and rasterised via AndroidSVG - AlarmManager (~15 min inexact), WorkManager (~30 min with network constraint), @@ -248,15 +248,15 @@ Supported locales are auto-detected from ARB files. | Package | Purpose | |---------|---------| -| home_widget | Android/iOS widget support | -| geolocator | GPS location | -| geocoding | Reverse geocoding (city names) | -| http | API requests | -| path_provider | File storage | -| shared_preferences | Settings storage | +| http | API requests (weather, city search) | | intl | Locale-aware formatting | +| flutter_localizations | i18n framework | -Material You theming uses native Android color extraction (`MaterialYouColorExtractor.kt`). +Location (GPS + reverse geocoding), the widget KV bridge, persistent storage, and +Material You theming are all **native** (over the `org.bortnik.meteogram/svg` method +channel) — no `home_widget`, `geolocator`, `geocoding`, `path_provider`, or +`shared_preferences` packages. This keeps the project free of any Kotlin-Gradle-Plugin +plugin, as required by AGP-9 built-in Kotlin. ## License @@ -273,5 +273,4 @@ Contributions welcome! Please read the existing code style and test your changes ## Acknowledgments - Weather data: [Open-Meteo](https://open-meteo.com/) -- Widget support: [home_widget](https://pub.dev/packages/home_widget) - SVG rendering: [AndroidSVG](https://bigbadaboom.github.io/androidsvg/) diff --git a/SECURITY.md b/SECURITY.md index 44f92a9..1f64020 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -27,7 +27,7 @@ We aim to respond to security reports within **48 hours** and provide a fix with ### Dependency Management - **Automated scanning**: Dependabot monitors dependencies weekly -- **Pinned versions**: Critical dependencies (like `home_widget`) are pinned for stability +- **Pinned toolchain**: The Flutter SDK and Android Gradle Plugin are pinned for build stability - **Regular updates**: Dependencies are reviewed and updated monthly - **Vulnerability tracking**: All dependencies checked against [GitHub Advisory Database](https://github.com/advisories) @@ -58,12 +58,6 @@ This app uses these third-party services: ## Known Security Considerations -### home_widget Version Pin - -This project intentionally uses `home_widget: 0.8.0` (not latest 0.9.0) due to functional issues with widget resizing. See [docs/HOME_WIDGET_VERSION_ISSUE.md](docs/HOME_WIDGET_VERSION_ISSUE.md) for details. - -**Security impact**: `JobIntentService` (used in 0.8.0) is deprecated but still functional and secure. We monitor for security advisories and will migrate when 0.9.0+ fixes the resize issue. - ### Permissions The app requests these Android permissions: diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 006102f..cd24bfe 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -20,7 +20,12 @@ android { defaultConfig { applicationId = "org.bortnik.meteogram" - minSdk = flutter.minSdkVersion + // minSdk 30 (Android 11). The hard floor is 29: the home-screen widget's theme + // parent android:Theme.DeviceDefault.DayNight requires API 29, so on API 24-28 + // the launcher couldn't inflate the widget (broken widget). API 30 additionally + // provides LocationListener default callbacks (see LocationProvider). Overrides + // Flutter's default minSdk (24); do not lower below 29 without re-checking WidgetTheme. + minSdk = 30 targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName diff --git a/android/app/src/main/kotlin/org/bortnik/meteogram/LocationProvider.kt b/android/app/src/main/kotlin/org/bortnik/meteogram/LocationProvider.kt index 4a3288f..87d2ea0 100644 --- a/android/app/src/main/kotlin/org/bortnik/meteogram/LocationProvider.kt +++ b/android/app/src/main/kotlin/org/bortnik/meteogram/LocationProvider.kt @@ -6,8 +6,6 @@ import android.content.pm.PackageManager import android.location.Location import android.location.LocationListener import android.location.LocationManager -import android.os.Build -import android.os.Bundle import android.os.Handler import android.os.Looper import android.util.Log @@ -32,13 +30,8 @@ object LocationProvider { fun isLocationServiceEnabled(context: Context): Boolean { val lm = context.getSystemService(Context.LOCATION_SERVICE) as? LocationManager ?: return false - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - lm.isLocationEnabled - } else { - @Suppress("DEPRECATION") - (lm.isProviderEnabled(LocationManager.GPS_PROVIDER) || - lm.isProviderEnabled(LocationManager.NETWORK_PROVIDER)) - } + // isLocationEnabled is API 28+; minSdk is 29, so it's always available. + return lm.isLocationEnabled } /** "granted" if fine or coarse location is held, else "denied". */ @@ -76,7 +69,6 @@ object LocationProvider { * once on the main thread with `[lat, lon]`, or null on timeout/failure. Call * this on the main thread. */ - @Suppress("DEPRECATION") // onStatusChanged override is needed for minSdk 24 runtime safety. fun getCurrentPosition(context: Context, timeoutMs: Long, callback: (DoubleArray?) -> Unit) { if (!hasPermission(context)) { callback(null); return } val lm = context.getSystemService(Context.LOCATION_SERVICE) as? LocationManager @@ -98,13 +90,8 @@ object LocationProvider { handler.removeCallbacksAndMessages(null) callback(doubleArrayOf(location.latitude, location.longitude)) } - - // onStatusChanged/onProviderEnabled/onProviderDisabled gained default - // implementations only in API 30; override them so the class is safe on - // API 24 (where the framework still invokes them on the interface). - override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {} - override fun onProviderEnabled(provider: String) {} - override fun onProviderDisabled(provider: String) {} + // onStatusChanged/onProviderEnabled/onProviderDisabled use the + // LocationListener default impls (API 30+), so no stubs are needed. } try { diff --git a/docs/EXPEDITED_WORKMANAGER_PLAN.md b/docs/EXPEDITED_WORKMANAGER_PLAN.md deleted file mode 100644 index 8e1618b..0000000 --- a/docs/EXPEDITED_WORKMANAGER_PLAN.md +++ /dev/null @@ -1,211 +0,0 @@ -# Expedited WorkManager: Replacing home_widget - -## Background - -The `home_widget` package provides Flutter ↔ native bridging for widget callbacks. However: - -- **v0.7.0+1 / v0.8.0**: Uses `JobIntentService` (immediate execution, but deprecated API 30) -- **v0.8.1+**: Uses `WorkManager` without `setExpedited()` (delayed execution) - -We're currently pinned to v0.8.0 for immediate execution. This document outlines how to replace home_widget entirely with our own expedited WorkManager implementation. - -## Why Replace home_widget? - -1. **JobIntentService is deprecated** - May be removed in future Android versions -2. **Full control** - Can use `setExpedited()` for immediate execution -3. **Reduced dependencies** - One less package to maintain -4. **Modern API** - WorkManager is the recommended approach - -## Implementation Plan - -### New Files to Create - -#### 1. FlutterBackgroundWorker.kt - -Expedited `CoroutineWorker` that starts a `FlutterEngine` and executes Dart callbacks. - -```kotlin -class FlutterBackgroundWorker( - context: Context, - params: WorkerParameters -) : CoroutineWorker(context, params) { - - override suspend fun doWork(): Result { - // 1. Get callback handle from SharedPreferences - // 2. Start FlutterEngine if not running - // 3. Execute Dart callback via MethodChannel - // 4. Wait for completion - return Result.success() - } - - // Required for Android < 12 backward compatibility - override suspend fun getForegroundInfo(): ForegroundInfo { - return ForegroundInfo( - NOTIFICATION_ID, - createNotification() // Silent notification - ) - } - - companion object { - fun enqueue(context: Context, uri: Uri) { - val data = Data.Builder() - .putString("uri", uri.toString()) - .build() - - val request = OneTimeWorkRequestBuilder() - .setInputData(data) - .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) - .build() - - WorkManager.getInstance(context) - .enqueueUniqueWork("flutter_callback", ExistingWorkPolicy.APPEND, request) - } - } -} -``` - -Key difference from home_widget 0.9.0: **`setExpedited()`** for immediate execution. - -#### 2. FlutterBackgroundIntent.kt - -Replacement for `HomeWidgetBackgroundIntent`: - -```kotlin -object FlutterBackgroundIntent { - fun trigger(context: Context, uri: Uri) { - FlutterBackgroundWorker.enqueue(context, uri) - } -} -``` - -### Files to Modify - -#### 1. MeteogramWidgetProvider.kt - -Change from: -```kotlin -class MeteogramWidgetProvider : HomeWidgetProvider() -``` - -To: -```kotlin -class MeteogramWidgetProvider : AppWidgetProvider() { - override fun onUpdate( - context: Context, - appWidgetManager: AppWidgetManager, - appWidgetIds: IntArray - ) { - // Direct implementation without HomeWidgetProvider - } -} -``` - -#### 2. WidgetUtils.kt - -Replace: -```kotlin -es.antonborri.home_widget.HomeWidgetBackgroundIntent.getBroadcast(context, uri).send() -``` - -With: -```kotlin -FlutterBackgroundIntent.trigger(context, uri) -``` - -#### 3. background_service.dart - -Replace: -```dart -await HomeWidget.registerInteractivityCallback(homeWidgetBackgroundCallback); -``` - -With custom registration that stores callback handle in SharedPreferences. - -#### 4. All Flutter files using HomeWidget - -| Old (home_widget) | New (direct) | -|-------------------|--------------| -| `HomeWidget.saveWidgetData(key, value)` | `SharedPreferences.setX(key, value)` | -| `HomeWidget.getWidgetData(key)` | `SharedPreferences.getX(key)` | -| `HomeWidget.updateWidget(androidName: ...)` | `MethodChannel` → native `AppWidgetManager.updateAppWidget()` | - -### Dependencies - -```kotlin -// android/app/build.gradle.kts -implementation("androidx.work:work-runtime-ktx:2.9.0") // Already added -``` - -```yaml -# pubspec.yaml -# Remove: home_widget: 0.8.0 -# Add: shared_preferences (already have) -``` - -## FlutterEngine in Background - -The key challenge is starting a `FlutterEngine` in a background worker. Here's how home_widget does it: - -```kotlin -// From HomeWidgetBackgroundWorker.kt (0.9.0) -private suspend fun initializeFlutterEngine() { - val callbackHandle = getDispatcherHandle(context) // From SharedPreferences - val callbackInfo = FlutterCallbackInformation.lookupCallbackInformation(callbackHandle) - - withContext(Dispatchers.Main) { - engine = FlutterEngine(context) - val callback = DartExecutor.DartCallback( - context.assets, - FlutterInjector.instance().flutterLoader().findAppBundlePath(), - callbackInfo, - ) - engine?.dartExecutor?.executeDartCallback(callback) - } -} -``` - -This pattern: -1. Stores Dart callback handle during app initialization -2. Looks up callback info when worker runs -3. Creates FlutterEngine on main thread -4. Executes Dart callback - -## Backward Compatibility (Android < 12) - -On Android 11 and earlier, expedited work runs as a **foreground service**. This requires: - -1. `getForegroundInfo()` implementation in worker -2. Notification channel setup -3. `FOREGROUND_SERVICE` permission in manifest - -```xml - -``` - -The notification can be silent/low-priority to minimize user disruption. - -## Estimated Effort - -| Task | Complexity | -|------|------------| -| FlutterBackgroundWorker.kt | Medium - adapt from home_widget source | -| FlutterBackgroundIntent.kt | Low - simple wrapper | -| MeteogramWidgetProvider changes | Low - mostly removing base class | -| WidgetUtils changes | Low - change import/call | -| Dart side changes | Medium - replace HomeWidget calls | -| Testing | Medium - verify all triggers work | - -**Total: ~1-2 days of focused work** - -## References - -- [WorkManager expedited jobs](https://developer.android.com/develop/background-work/background-tasks/persistent/getting-started/define-work) -- [home_widget 0.8.0 source](https://github.com/ABausG/home_widget/tree/v0.8.0) -- [home_widget 0.9.0 source](https://github.com/ABausG/home_widget/tree/v0.9.0) -- [FlutterEngine background execution](https://docs.flutter.dev/packages-and-plugins/background-processes) - -## Decision - -**Current state:** Using home_widget 0.8.0 (JobIntentService) - works but uses deprecated API. - -**Future:** When JobIntentService stops working or we want to modernize, implement this plan. diff --git a/docs/GITHUB_SECURITY_SETUP.md b/docs/GITHUB_SECURITY_SETUP.md index ce5f681..293626d 100644 --- a/docs/GITHUB_SECURITY_SETUP.md +++ b/docs/GITHUB_SECURITY_SETUP.md @@ -22,7 +22,7 @@ Dependabot is **enabled by default** when you push `.github/dependabot.yml`. It ✅ **Scan dependencies weekly** for known vulnerabilities ✅ **Create PRs automatically** to update vulnerable packages -✅ **Respect ignore rules** (e.g., `home_widget` stays pinned) +✅ **Respect ignore rules** for any intentionally pinned dependencies ✅ **Group updates** to reduce PR noise **What to expect:** @@ -185,29 +185,10 @@ Repository → Security tab --- -## Special Case: home_widget - -This project intentionally pins `home_widget: 0.8.0` due to a functional regression in 0.9.0+. See [docs/HOME_WIDGET_VERSION_ISSUE.md](HOME_WIDGET_VERSION_ISSUE.md). - -**Dependabot configuration:** -```yaml -ignore: - - dependency-name: "home_widget" - # Pinned to 0.8.0 - see HOME_WIDGET_VERSION_ISSUE.md -``` - -**If Dependabot suggests home_widget update:** -1. Check if 0.9.0+ fixed the WorkManager delay issue -2. Test widget resize on physical device -3. Only merge if resize works immediately - ---- - ## Security Scanning Results **Current Status (as of 2026-01-14):** - ✅ No known vulnerabilities in dependencies -- ✅ `shared_preferences_android` 2.4.18 (patched, CVE fixed) - ✅ `http` 1.6.0 (patched, header injection fixed) - ✅ No secrets detected - ✅ Flutter analyzer: 0 issues @@ -233,7 +214,7 @@ ignore: ### "Dependabot updating pinned dependency" - Add to `ignore` list in `dependabot.yml` -- Example already included for `home_widget` +- Add the dependency to the `ignore` list with a reason comment --- diff --git a/docs/LESSONS_LEARNED.md b/docs/LESSONS_LEARNED.md index 22a8930..79d08c9 100644 --- a/docs/LESSONS_LEARNED.md +++ b/docs/LESSONS_LEARNED.md @@ -30,7 +30,7 @@ Architectural decisions and insights discovered during development. - `e007d88` - Replace AlarmManager with WorkManager for battery efficiency **Related docs:** -- [NATIVE_SVG_RENDERING.md](NATIVE_SVG_RENDERING.md) - Technical architecture details +- [ai/widget.md](ai/widget.md) - Technical architecture details --- @@ -166,7 +166,7 @@ No Flutter, no Dart, no platform channels for widget updates. **Key insight:** SVG as an intermediate format gives you human-readable, scalable graphics that can be generated with simple string operations and rendered natively. **Related docs:** -- [NATIVE_SVG_RENDERING.md](NATIVE_SVG_RENDERING.md) - Full technical implementation details +- [ai/widget.md](ai/widget.md) - Full technical implementation details --- diff --git a/docs/MATERIAL_YOU_COLORS.md b/docs/MATERIAL_YOU_COLORS.md index 64fbd1c..e3db39d 100644 --- a/docs/MATERIAL_YOU_COLORS.md +++ b/docs/MATERIAL_YOU_COLORS.md @@ -68,15 +68,14 @@ Settings.Secure.THEME_CUSTOMIZATION_OVERLAY_PACKAGES changes (keys: material_you_light_temp, etc.) │ ▼ - WidgetUtils.triggerChartReRender() + WidgetUtils.rerenderAllWidgetsNative() │ ▼ - Flutter background callback - Reads colors from SharedPreferences - Generates new SVGs with updated colors + Native onUpdate() reads colors from SharedPreferences + SvgChartGenerator regenerates SVGs with updated colors │ ▼ - HomeWidget.updateWidget() + AppWidgetManager.updateAppWidget() Widget displays new colors ``` @@ -115,12 +114,14 @@ These are two separate concerns: | Broadcast | `ACTION_CONFIGURATION_CHANGED` | None | | Our solution | Dual SVGs with XML visibility | ContentObserver + fallbacks | -**Dark/light mode** is handled automatically by Android. The widget XML uses: +**Dark/light mode** is handled automatically by Android. The two chart +`ImageView`s take their visibility from night-qualified integer resources +(`res/values/integers.xml` vs `res/values-night/integers.xml`): ```xml - - +0 +2 ``` -RemoteViews switches visibility instantly when system theme changes - no app code needed. +The launcher re-inflates the RemoteViews when the system theme changes, flipping which chart shows - no app code needed. **Material You accent colors** require active detection because Android provides no broadcast. This is why we use the multi-layered approach (ContentObserver, WorkManager). @@ -139,10 +140,10 @@ WorkManager is used for content URI observation: ```kotlin // android/app/build.gradle.kts -implementation("androidx.work:work-runtime-ktx:2.9.0") +implementation("androidx.work:work-runtime-ktx:2.11.2") ``` -Note: This is separate from home_widget's WorkManager usage (which we avoid due to delayed execution - see HOME_WIDGET_VERSION_ISSUE.md). +Note: WorkManager here serves the Material You content-URI trigger and the periodic refresh; it is not a Flutter-bridge dependency (the app has no `home_widget` package). ## Testing diff --git a/docs/ai/architecture.md b/docs/ai/architecture.md index f7c5108..4ef247c 100644 --- a/docs/ai/architecture.md +++ b/docs/ai/architecture.md @@ -115,7 +115,7 @@ widgets slice their own view from this cache — the 48h chart takes 6h past + ### LocationService (`lib/services/location_service.dart`) Device location handling with fallback. Responsibilities: -- Get GPS coordinates via geolocator +- Get GPS coordinates via native `LocationProvider` (through `LocationBridge` over the method channel) - Handle permissions gracefully (no exceptions thrown) - Reverse geocoding for city name resolution - City search via Open-Meteo geocoding API @@ -250,20 +250,21 @@ catch (e) { ### Flutter/Dart | Package | Purpose | |---------|---------| -| (native method channel) | Flutter ↔ native widget bridge — `WidgetStore` over `org.bortnik.meteogram/svg` | -| geolocator | GPS location | -| geocoding | Reverse geocoding (coordinates → city name) | -| http | API requests (weather, city search) | -| path_provider | App documents for SVG files | -| shared_preferences | Settings storage | -| flutter_localizations | i18n framework | +| http | API requests (weather fetch, city search) | | intl | Locale-aware time formatting (DateFormat.j) | +| flutter_localizations | i18n framework | +| mocktail / flutter_lints | dev: test mocks and lints | -Material You theming uses native Android color extraction (`MaterialYouColorExtractor.kt`). +GPS, reverse geocoding, the widget KV bridge, and persistent storage are all +**native** (over the `org.bortnik.meteogram/svg` method channel) — there are no +`geolocator`, `geocoding`, `home_widget`, `path_provider`, or +`shared_preferences` packages. This keeps the project free of any +Kotlin-Gradle-Plugin plugin, as required by AGP-9 built-in Kotlin. Material You +theming uses native color extraction (`MaterialYouColorExtractor.kt`). ### Android Native | Library | Purpose | |---------|---------| | com.caverock:androidsvg-aar | SVG parsing and rendering | -See `docs/NATIVE_SVG_RENDERING.md` for detailed rendering architecture. +See `docs/ai/widget.md` for the widget rendering pipeline and background refresh. diff --git a/docs/ai/widget.md b/docs/ai/widget.md index 8510a30..0bc1d46 100644 --- a/docs/ai/widget.md +++ b/docs/ai/widget.md @@ -2,18 +2,24 @@ ## Overview -Native Android `AppWidgetProvider` with a method-channel key-value bridge (`WidgetStore`, -`lib/services/widget_store.dart`) to the shared `HomeWidgetPreferences` SharedPreferences file. -The chart is generated as SVG natively in Kotlin (`SvgChartGenerator.kt`), then rendered using -the AndroidSVG library. Both the widget and the in-app chart use the same native SVG generation -for consistency. +The app ships **two native Android home-screen widgets**, both backed by +`AppWidgetProvider` (no `home_widget` package): -> **Note (2026-05):** Sections below describing the `home_widget` package, `registerInteractivityCallback`, -> and a Dart `homeWidgetBackgroundCallback` are **outdated**. The `home_widget` dependency was removed; -> Flutter↔native KV now goes through `WidgetStore` over the `org.bortnik.meteogram/svg` channel, and all -> background refresh is native Kotlin (AlarmManager/WorkManager/BootReceiver) with no Dart callbacks. +- **`MeteogramWidgetProvider`** — the default 48-hour meteogram. +- **`MeteogramWeeklyWidgetProvider`** — a 7-day variant that `extends` + `MeteogramWidgetProvider` and only overrides the layout, time range, and + X-axis labels (`labelStepHours = 24`, `TimeLabelFormat.WEEKDAY`). -See `docs/NATIVE_SVG_RENDERING.md` for detailed architecture. +The chart is generated as an SVG string natively in Kotlin +(`SvgChartGenerator.kt`) and rasterised with the AndroidSVG library. The same +generator drives both widgets and the in-app chart, so they always match. + +Flutter ↔ native key-value storage and the widget-refresh trigger go through +**`WidgetStore`** (`lib/services/widget_store.dart`) over the +`org.bortnik.meteogram/svg` method channel — see `MainActivity` +(`getWidgetData` / `saveWidgetData` / `updateWidget`). **All background refresh +is native Kotlin** (AlarmManager / WorkManager / boot receiver); there is no +Dart background callback and no Flutter engine involved in background updates. ## Critical: RemoteViews Limitations @@ -29,22 +35,16 @@ Android `RemoteViews` only supports a limited set of views: **If you get "Can't load widget" error, check for unsupported views in the layout XML.** -## Package Setup - -### pubspec.yaml -```yaml -dependencies: - home_widget: ^0.8.0 - path_provider: ^2.1.0 -``` - ## Android Configuration ### AndroidManifest.xml + +The manifest declares the two widget providers plus the receivers that drive +background refresh (no `home_widget` receivers/services): + ```xml - - + + @@ -52,484 +52,198 @@ dependencies: android:resource="@xml/meteogram_widget_info" /> - - + + - + + - -``` - -### res/xml/meteogram_widget_info.xml -```xml - - - -``` - -### res/layout/meteogram_widget.xml -```xml - - - - - - - - - - - - - -``` - -### res/values/integers.xml (Light Mode) -```xml - - - - 0 - 2 - -``` - -### res/values-night/integers.xml (Dark Mode) -```xml - - - - 2 - 0 - -``` + + + + + + + -### res/drawable/widget_background.xml -```xml - - - - - -``` + + + + + + -### Widget Provider (Kotlin) -```kotlin -package com.example.widget - -import android.appwidget.AppWidgetManager -import android.content.Context -import android.content.SharedPreferences -import android.graphics.BitmapFactory -import android.view.View -import android.widget.RemoteViews -import android.app.PendingIntent -import android.content.Intent -import es.antonborri.home_widget.HomeWidgetProvider - -class MeteogramWidgetProvider : HomeWidgetProvider() { - override fun onUpdate( - context: Context, - appWidgetManager: AppWidgetManager, - appWidgetIds: IntArray, - widgetData: SharedPreferences - ) { - for (appWidgetId in appWidgetIds) { - val views = RemoteViews(context.packageName, R.layout.meteogram_widget) - - // Tap to open app - val intent = Intent(context, MainActivity::class.java) - val pendingIntent = PendingIntent.getActivity( - context, 0, intent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - views.setOnClickPendingIntent(R.id.widget_root, pendingIntent) - - // Update temperature - val temperature = widgetData.getString("current_temperature", "--°") - views.setTextViewText(R.id.widget_temperature, temperature) - - // Update location - val location = widgetData.getString("location_name", "") - views.setTextViewText(R.id.widget_location, location) - - // Update chart image - val imagePath = widgetData.getString("meteogram_image", null) - if (imagePath != null) { - try { - val bitmap = BitmapFactory.decodeFile(imagePath) - if (bitmap != null) { - views.setImageViewBitmap(R.id.widget_chart, bitmap) - views.setViewVisibility(R.id.widget_chart, View.VISIBLE) - views.setViewVisibility(R.id.widget_placeholder, View.GONE) - } - } catch (e: Exception) { - // Keep placeholder visible - } - } - - appWidgetManager.updateAppWidget(appWidgetId, views) - } - } -} + + + + + + ``` -## Flutter Integration - -All SVG generation happens in Kotlin (`SvgChartGenerator.kt`). The Flutter -side only displays the generated SVG and triggers refreshes. - -### SVG Chart Generator (Kotlin — `android/.../SvgChartGenerator.kt`) - -Single source of truth for chart rendering: used by both widgets (48h and -weekly) and the in-app panels. `generate()` takes the hourly data slice plus -dimensions, locale, Fahrenheit flag, label step, and label format, and -returns an SVG string. - -**Transparent background requirement.** The SVG must not include a background -rect. The widget layout uses `?android:attr/colorBackground` with 80% alpha -so widgets match system chrome (Search bar, Clock, etc.). A solid SVG -background would override this and break the translucent look. - -### Method Channel (`lib/services/native_svg_service.dart`) +### res/xml/meteogram_widget_info.xml -Dart calls Kotlin over a single method channel (`org.bortnik.meteogram/svg`): +`updatePeriodMillis="1800000"` (30 min) is the OEM-resistant system fallback. +The API-31+ attributes (`targetCellWidth/Height`, `maxResize*`, `previewLayout`) +are ignored on older devices. The weekly variant uses +`meteogram_widget_weekly_info.xml` with the same shape but its own +`initialLayout`/`previewLayout`/`description`. -- `fetchWeather(lat, lon)` — Kotlin `WeatherFetcher` calls Open-Meteo and - writes the result to SharedPreferences. -- `generateSvg(mode, width, height, isLight, usesFahrenheit)` — Kotlin reads - the cache and returns an SVG string for the requested mode (`hourly` or - `weekly`). -- Various getters for the cached temperature, fetch timestamp, cached city, - etc. +### res/layout/meteogram_widget.xml -### In-App Chart Display (`lib/widgets/native_svg_chart_view.dart`) +The root `FrameLayout` carries `android:theme="@style/WidgetTheme"` and paints +`?android:attr/colorBackground` at 80% alpha so the widget matches system +chrome (Search bar, Clock). It contains two chart `ImageView`s (light + dark), +a placeholder `TextView` ("Tap to load forecast"), and a hidden refresh +indicator. The weekly layout (`meteogram_widget_weekly.xml`) mirrors it. -PlatformView wrapper that embeds a native Android view showing the SVG: +> **minSdk note:** `WidgetTheme`'s parent `android:Theme.DeviceDefault.DayNight` +> requires **API 29**, which is why `minSdk` is pinned ≥ 29 (currently 30). On +> API 24–28 the launcher can't inflate the widget. See CLAUDE.md Gotcha #9. -```dart -class NativeSvgChartView extends StatefulWidget { - final String svgString; - final double width; - final double height; - - @override - Widget build(BuildContext context) { - return AndroidView( - viewType: 'svg_chart_view', - creationParams: {'svg': svgString, 'width': width, 'height': height}, - ); - } -} -``` +### Chart theme visibility (light vs dark) -## Background Refresh - -### Periodic Updates (WorkManager) - -Background refresh uses WorkManager for battery efficiency - the OS batches work with other apps: - -```kotlin -// WeatherUpdateWorker.kt - periodic weather refresh -class WeatherUpdateWorker(context: Context, params: WorkerParameters) : Worker(context, params) { - companion object { - fun enqueue(context: Context) { - val constraints = Constraints.Builder() - .setRequiredNetworkType(NetworkType.CONNECTED) - .build() - - val workRequest = PeriodicWorkRequestBuilder( - 30, TimeUnit.MINUTES // OS may batch/delay for battery - ).setConstraints(constraints).build() - - WorkManager.getInstance(context).enqueueUniquePeriodicWork( - "periodic_weather_update", - ExistingPeriodicWorkPolicy.KEEP, - workRequest - ) - } - } - - override fun doWork(): Result { - if (WidgetUtils.isWeatherDataStale(applicationContext)) { - WidgetUtils.fetchWeather(applicationContext) - } else { - WidgetUtils.rerenderAllWidgets(applicationContext) - } - return Result.success() - } -} -``` +There is no per-widget bitmap caching of "the current theme"; instead the +layout holds **both** charts and the system/launcher decides which is visible: -### BackgroundService Initialization -```dart -class BackgroundService { - static Future initialize() async { - // Register HomeWidget callback for native events - await HomeWidget.registerInteractivityCallback(homeWidgetBackgroundCallback); - } -} -``` +- `res/values/integers.xml` → `chart_light_visibility=0` (visible), + `chart_dark_visibility=2` (gone). +- `res/values-night/integers.xml` → the inverse. -### Event-Driven Refresh +When the system theme changes, the launcher re-inflates the RemoteViews and the +night-qualified integers flip which `ImageView` shows — no app code required. +A manual in-app theme choice (System/Light/Dark, mirrored to the +`theme_mode` pref) overrides this in `MeteogramWidgetProvider.applyThemeOverride()` +via `WidgetUtils.chartVisibilityForThemeMode()`, which also forces the card +background to match the chosen mode. -The widget responds to system events via broadcast receivers. +### Provider rendering (Kotlin) -#### Events Handled +`onUpdate()` (in `MeteogramWidgetProvider`, inherited by the weekly provider): -| Event | Android Action | Registration | Response | -|-------|----------------|--------------|----------| -| Device boot | `ACTION_BOOT_COMPLETED` | Manifest | Fetch if stale, re-render, schedule alarm | -| Alarm (15 min) | Custom action | AlarmManager | Fetch if stale, re-render if needed | -| Widget resize | `onAppWidgetOptionsChanged` | N/A | Re-render immediately | -| Locale change | `ACTION_LOCALE_CHANGED` | Manifest | Re-render all widgets (no fetch) | -| Timezone change | `ACTION_TIMEZONE_CHANGED` | Manifest | Re-render all widgets (no fetch) | -| Periodic (~30 min) | `WeatherUpdateWorker` | WorkManager | Fetch if stale, re-render all widgets (has `NetworkType.CONNECTED` constraint) | -| System fallback | `updatePeriodMillis` | Widget XML | Re-render (OEM-resistant) | +1. Reads the cached weather via `WeatherDataParser.parseFromPrefs(context)`. +2. Picks the slice for this provider via `chartView(weatherData)` — + `getHourlyView()` for 48h, `getWeeklyView()` for the weekly subclass. +3. Generates light **and** dark SVGs with `SvgChartGenerator.generate(...)` and + rasterises each to a `Bitmap` in memory (`generateChartBitmap`), then + `setImageViewBitmap` on the two `ImageView`s. +4. Falls back to any previously-saved SVG file paths (`svg_path_light_` / + `svg_path_dark_`) if in-memory generation fails. +5. If there's still no chart (e.g. no weather cached yet), shows the + "Tap to load forecast" placeholder; tapping opens `MainActivity`, which + fetches and refreshes both widgets. -**Important:** `LOCALE_CHANGED` and `TIMEZONE_CHANGED` use manifest-declared receivers because the app process is killed when locale/timezone changes. Runtime-registered receivers are lost when the process dies. These broadcasts are exempt from Android 8.0+ implicit broadcast restrictions. +`onAppWidgetOptionsChanged()` handles resize: it stores the new pixel +dimensions and regenerates immediately (or triggers a fetch if there's no data). -#### Two-Operation Pattern +## Flutter Integration -**Key design:** Separate weather fetching from chart rendering to minimize unnecessary API calls. +All SVG generation and weather fetching happen in Kotlin. The Flutter side only +displays the generated SVG and triggers refreshes. -```dart -// HomeWidget background callback (handles native events) -// IMPORTANT: URI hosts are always lowercase! -@pragma('vm:entry-point') -Future homeWidgetBackgroundCallback(Uri? uri) async { - WidgetsFlutterBinding.ensureInitialized(); // Required for headless execution - switch (uri?.host.toLowerCase()) { - case 'weatherupdate': - await _updateWeatherData(); // Fetch + cache + render - break; - case 'chartrerender': - await _reRenderCharts(uri); // Render from cache only (pass URI for params) - break; - } -} - -// Re-render from cached data (no network call) -// URI params: width, height, locale (all optional, fallback to cached/Platform values) -Future _reRenderCharts([Uri? uri]) async { - // Extract params from URI (more reliable than Platform.localeName in cold-start) - final uriWidth = int.tryParse(uri?.queryParameters['width'] ?? ''); - final uriHeight = int.tryParse(uri?.queryParameters['height'] ?? ''); - final uriLocale = uri?.queryParameters['locale']; - - final cachedJson = await HomeWidget.getWidgetData('cached_weather'); - if (cachedJson == null) return; - - final weather = WeatherData.fromJson(jsonDecode(cachedJson)); - final latitude = await HomeWidget.getWidgetData('cached_latitude') ?? 0.0; - final longitude = await HomeWidget.getWidgetData('cached_longitude') ?? 0.0; - - await _generateSvgCharts(weather, latitude, longitude, - uriWidth: uriWidth, uriHeight: uriHeight, uriLocale: uriLocale); - await HomeWidget.updateWidget(androidName: 'MeteogramWidgetProvider'); -} -``` +### Method channel (`org.bortnik.meteogram/svg`) -**CRITICAL: Locale Passing via URI** +Dart talks to Kotlin over a single channel. Relevant methods (see +`MainActivity.configureFlutterEngine`): -`Platform.localeName` is stale in background isolates - it returns the locale from when the Flutter engine started, not the current system locale. When the system locale changes, native code must pass the current locale via URI query params: +| Method | Purpose | +|--------|---------| +| `fetchWeather(latitude, longitude)` | `WeatherFetcher` calls Open-Meteo and writes the result to SharedPreferences | +| `generateSvg(mode, width, height, isLight, usesFahrenheit)` | Reads the cache and returns an SVG string for `hourly` or `weekly` | +| `renderSvg(svg, width, height)` | Rasterises an SVG string to PNG bytes | +| `reverseGeocode(latitude, longitude)` | `android.location.Geocoder` → city name | +| `getWidgetData` / `saveWidgetData` | KV read/write to the shared prefs file (used by `WidgetStore`) | +| `updateWidget(name)` | Sends `ACTION_APPWIDGET_UPDATE` to the named provider | +| `isLocationServiceEnabled` / `checkLocationPermission` / `requestLocationPermission` / `getCurrentPosition` / `getLastKnownPosition` / `openLocationSettings` | Native location surface (`LocationProvider`, used by `LocationBridge`) | -```kotlin -// Native side: pass current locale in URI -val locale = java.util.Locale.getDefault() -val localeStr = "${locale.language}_${locale.country}" // e.g., "en_US", "uk_UA" +### WidgetStore (`lib/services/widget_store.dart`) -HomeWidgetBackgroundIntent.getBroadcast( - context, - Uri.parse("homewidget://chartReRender?width=$widthPx&height=$heightPx&locale=$localeStr") -).send() -``` +Thin KV bridge plus `updateWidget`. It writes to the shared +**`HomeWidgetPreferences`** SharedPreferences file — that filename is retained +for backward compatibility, and the native side replicates the old +`home_widget` serialization (a companion `home_widget.double.` flag and +`doubleToRawLongBits` for doubles) so data from pre-migration installs survives +an upgrade. ```dart -// Dart side: prefer URI locale over Platform.localeName -Locale systemLocale; -if (uriLocale != null && uriLocale.isNotEmpty) { - final parts = uriLocale.split('_'); - systemLocale = parts.length >= 2 - ? Locale(parts[0], parts[1].toUpperCase()) - : Locale(parts[0]); -} else { - systemLocale = _getSystemLocale(); // Fallback to Platform.localeName -} -final usesFahrenheit = UnitsService.usesFahrenheit(systemLocale); +await WidgetStore.saveWidgetData('use_gps', true); +final lat = await WidgetStore.getWidgetData('cached_latitude'); +await WidgetStore.updateWidget(androidName: 'MeteogramWidgetProvider'); ``` -#### Staleness Check (Native Side) - -```kotlin -// WidgetEventReceiver.kt -private fun fetchWeatherIfStale(context: Context) { - val prefs = context.getSharedPreferences("HomeWidgetPreferences", Context.MODE_PRIVATE) - val lastUpdate = prefs.getLong("last_weather_update", 0) - val staleThreshold = 15 * 60 * 1000L // 15 minutes +`WidgetService.triggerWidgetUpdate()` refreshes **both** providers +(`MeteogramWidgetProvider` and `MeteogramWeeklyWidgetProvider`). - if (System.currentTimeMillis() - lastUpdate > staleThreshold) { - triggerWeatherFetch(context) // Via HomeWidgetBackgroundIntent - } -} -``` +### In-app chart display (`lib/widgets/native_svg_chart_view.dart`) -#### Android Architecture +A PlatformView (`AndroidView`, viewType `svg_chart_view`) embeds a native +Android view that renders the SVG via AndroidSVG — bypassing Flutter's +compositor for 1:1 pixel rendering. The factory is registered in +`MainActivity` (`SvgChartViewFactory` → `SvgChartPlatformView`). -``` -┌─────────────────────────────────────────────────────────────┐ -│ MeteogramApplication │ -│ onCreate(): │ -│ - Registers theme ContentObserver (Material You) │ -│ - Schedules WidgetAlarmScheduler (15-min inexact alarm) │ -│ - Enqueues WeatherUpdateWorker (WorkManager) │ -└─────────────────────────────────────────────────────────────┘ - -┌─────────────────────────────────────────────────────────────┐ -│ AndroidManifest.xml │ -│ Declares receivers for: │ -│ - WidgetEventReceiver: LOCALE_CHANGED, TIMEZONE_CHANGED │ -│ - WidgetAlarmReceiver: Custom alarm action │ -│ - BootCompletedReceiver: ACTION_BOOT_COMPLETED │ -└─────────────────────────────────────────────────────────────┘ - -┌─────────────────────────────────────────────────────────────┐ -│ Update Trigger Sources │ -├─────────────────────────────────────────────────────────────┤ -│ BootCompletedReceiver → Immediate refresh on boot │ -│ WidgetAlarmReceiver → Every ~15 min (catches up on wake)│ -│ WidgetEventReceiver → Locale/timezone changes │ -│ WeatherUpdateWorker → Every ~30 min (network required) │ -│ updatePeriodMillis → Every 30 min (system fallback) │ -└─────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ WidgetUtils │ -│ - isWeatherDataStale() → check 15-min threshold │ -│ - fetchWeather() → WeatherFetcher → re-render on success │ -│ - rerenderAllWidgetsIfNeeded() → check 30-min boundary │ -│ - rerenderAllWidgetsNative() → trigger onUpdate() │ -└─────────────────────────────────────────────────────────────┘ -``` +## Background Refresh (fully native) -**Broadcast Registration Notes:** -- `LOCALE_CHANGED`, `TIMEZONE_CHANGED`: Manifest (app killed on change, exempt from 8.0+ restrictions) -- `BOOT_COMPLETED`: Manifest (requires RECEIVE_BOOT_COMPLETED permission) -- Custom alarm action: Manifest for WidgetAlarmReceiver -- Network availability: Handled by WorkManager with `NetworkType.CONNECTED` constraint +No Dart runs in the background. Updates are driven by layered native +mechanisms, all coordinated through `WidgetUtils`: -#### "Now" Indicator Updates +| Source | Cadence | Behaviour | +|--------|---------|-----------| +| `WidgetAlarmReceiver` (AlarmManager) | ~15 min, inexact | Fetch if stale, re-render if needed; catches up on wake | +| `WeatherUpdateWorker` (WorkManager) | ~30 min | `NetworkType.CONNECTED` constraint; fetch if stale, re-render | +| `BootCompletedReceiver` | On boot | Immediate refresh, reschedule alarm | +| `updatePeriodMillis` | 30 min | System fallback, OEM-resistant | +| `WidgetEventReceiver` | On event | `LOCALE_CHANGED` / `TIMEZONE_CHANGED` → re-render all (no fetch) | +| `onAppWidgetOptionsChanged` | On resize | Re-render immediately | -The "now" indicator snaps to the nearest hour at the 30-minute mark (e.g., 2:29 shows "now" at 2:00, 2:30 shows "now" at 3:00). +`MeteogramApplication.onCreate()` registers the Material You `ContentObserver`, +schedules the alarm (`WidgetAlarmScheduler`), and enqueues the worker. -Updates happen via layered mechanisms: -1. **AlarmManager (15 min)** - Inexact alarm, catches up on wake if missed during sleep -2. **WorkManager (~30 min)** - Network-dependent weather fetch -3. **BOOT_COMPLETED** - Immediate refresh after device boot -4. **updatePeriodMillis (30 min)** - System fallback, OEM-resistant +### WidgetUtils helpers (`WidgetUtils.kt`) -Re-render is triggered only when needed: -- A 30-minute boundary was crossed since last render (indicator position changed) -- Weather data was updated (background fetch completed) +- `isWeatherDataStale(context)` — true if `last_weather_update` is older than + the 15-min threshold (`STALE_THRESHOLD_MS`). +- `isRerenderNeeded(context)` — true if a 30-min boundary was crossed since the + last render (the "now" indicator moved) **or** weather was fetched since. +- `fetchWeather(context, pendingResult?)` — async fetch on a background + executor via `WeatherFetcher.fetchAndUpdateSync`; `fetchWeatherSync` is the + blocking variant for `WorkManager.doWork()`. +- `rerenderAllWidgetsNative(context)` — sends `ACTION_APPWIDGET_UPDATE` to + **every** provider in `WIDGET_PROVIDERS` (48h + weekly). +- `rerenderAllWidgetsIfNeeded(context)` — `rerenderAllWidgetsNative` guarded by + `isRerenderNeeded`. -This approach provides consistent behavior regardless of whether the app process is running. +### "Now" indicator updates -### main.dart setup -```dart -void main() async { - WidgetsFlutterBinding.ensureInitialized(); - await BackgroundService.initialize(); - runApp(const MyApp()); -} -``` +The "now" indicator snaps to the nearest hour at the 30-minute mark (2:29 → +"now" at 2:00; 2:30 → "now" at 3:00). Re-render fires only when the 30-min slot +changes or weather was refreshed, so the indicator stays correct whether or not +the app process is alive. ## Data Flow -1. **App loads weather** from Open-Meteo API -2. **SVG generated** in Dart via `SvgChartGenerator` (both light AND dark themes) -3. **SVG files saved** to app documents folder (`meteogram_light.svg`, `meteogram_dark.svg`) -4. **Widget data saved** via HomeWidget.saveWidgetData → SharedPreferences (SVG paths) -5. **Native provider** reads SVG, renders via AndroidSVG → Bitmap → ImageView -6. **In-app display** uses same pipeline via PlatformView (AndroidView) -7. **Theme switching** handled automatically by Android resource system - -**Key benefit:** SVG generation works in background isolates (no Flutter UI required), enabling true background updates. - -## Automatic Theme Switching - -Android widgets cannot receive `ACTION_CONFIGURATION_CHANGED` when the app is not running. Solution: dual bitmaps with night-qualified resources. - -### How It Works -1. **Flutter renders both themes** - Light and dark charts are captured on every update -2. **Widget has two ImageViews** - One for light (`widget_chart_light`), one for dark (`widget_chart_dark`) -3. **Visibility via resources** - `values/integers.xml` shows light, `values-night/integers.xml` shows dark -4. **Launcher handles switching** - When system theme changes, launcher re-inflates widget with new visibility values - -### Color System -```dart -// lib/theme/app_theme.dart -class MeteogramColors { - static const light = MeteogramColors( - temperatureLine: Color(0xFFFF6B6B), - precipitationBar: Color(0xFF4ECDC4), - // ... full palette for light theme - ); - - static const dark = MeteogramColors( - temperatureLine: Color(0xFFFF7675), - precipitationBar: Color(0xFF00CEC9), - // ... full palette for dark theme - ); -} -``` - -### Trade-off -Doubles storage (two PNG files instead of one), but enables instant theme switching without any app code running. +1. **App fetches weather**: `home_screen.dart` resolves location + (`LocationService` → native `LocationBridge`), then calls + `NativeSvgService.fetchWeather(lat, lon)` → Kotlin `WeatherFetcher` hits + Open-Meteo and caches the JSON to SharedPreferences. +2. **In-app chart**: Dart calls `generateSvg` → Kotlin reads the cache and + returns an SVG string → `NativeSvgChartView` renders it. +3. **Widget chart**: native `onUpdate` reads the cache, generates light+dark + SVGs with `SvgChartGenerator`, rasterises via AndroidSVG → `Bitmap` → + `ImageView`. +4. **Theme switching**: handled by night-qualified resources (system) or + `applyThemeOverride` (explicit choice). + +**Key benefit:** SVG generation runs entirely in Kotlin, so background updates +need no Flutter engine. ## Daylight Calculation -The meteogram displays daylight intensity as yellow bars, calculated using scientifically-grounded formulas. +The meteogram displays daylight intensity as yellow bars, calculated using +scientifically-grounded formulas (implemented natively in `SvgChartGenerator.kt`). ### Solar Elevation (Astronomical) @@ -557,7 +271,7 @@ Reference: Meeus, J. (1991). *Astronomical Algorithms*. Willmann-Bell. The simple `elevation / 90°` formula gives zero light at sunrise/sunset, which is incorrect. Instead, we use an atmospheric model that accounts for optical air mass: -```dart +``` // Atmospheric refraction constant x = 753.66156 @@ -633,9 +347,9 @@ Reference: Rainfall-MOR relationship studies, e.g., https://doi.org/10.20937/ATM ### Combined Formula -```dart +``` // Clear-sky illuminance from atmospheric model (0 to ~130,000 lux) -clearSkyLux = _clearSkyIlluminance(solarElevation) +clearSkyLux = clearSkyIlluminance(solarElevation) // Normalize to 0-1 range potential = clearSkyLux / 130000 @@ -659,31 +373,26 @@ The square root scaling provides a gentle boost to small values so winter/overca ### Logcat Commands ```bash -# Widget errors +# 48h widget adb logcat | grep -i "MeteogramWidget" +# Weekly widget +adb logcat | grep -i "MeteogramWeeklyWidget" + # Layout inflation errors adb logcat | grep -i "Error inflating" - -# Home widget messages -adb logcat | grep -i "HomeWidget" ``` ### Common Issues | Issue | Cause | Solution | |-------|-------|----------| -| "Can't load widget" | Unsupported view in layout | Remove View/Space elements | -| Widget shows placeholder | Image path not saved | Check saveWidgetData call | -| Temperature shows "--°" | No weather data | Check API call | -| Widget not updating | WorkManager not enqueued | Check WeatherUpdateWorker.enqueue in MeteogramApplication | - -## iOS Widget (Not Implemented) +| "Can't load widget" | Unsupported view in layout, or `WidgetTheme` unavailable below API 29 | Remove `View`/`Space`; keep `minSdk ≥ 29` | +| Widget shows "Tap to load forecast" | No weather cached yet | Open the app once (or wait for a background fetch with a stored location) | +| Widget not updating | Alarm/worker not scheduled | Check `MeteogramApplication.onCreate` scheduled the alarm and enqueued `WeatherUpdateWorker` | +| Weekly widget blank but 48h works | Weekly view slice empty | Check `WeatherData.getWeeklyView()` and that the fetch covers the full forecast window | -iOS widget extension would require: -1. Create WidgetKit extension in Xcode -2. Add App Groups capability -3. Implement TimelineProvider in Swift -4. Share data via UserDefaults with shared app group +## Platform -The current implementation focuses on Android. +Android-only. There is no iOS widget; an iOS port would require a WidgetKit +extension, a `TimelineProvider`, and an App Group for shared storage.