diff --git a/.github/workflows/codeql-default.yml b/.github/workflows/codeql-default.yml
index bc9fc30..c14e378 100644
--- a/.github/workflows/codeql-default.yml
+++ b/.github/workflows/codeql-default.yml
@@ -39,7 +39,7 @@ jobs:
uses: subosito/flutter-action@v2
with:
channel: 'stable'
- flutter-version: '3.41.7' # PINNED: 3.44.0 breaks builtInKotlin=true (KGP force-apply) — see CLAUDE.md
+ flutter-version: '3.44.0'
cache: true
- name: Cache Gradle dependencies
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 6a35508..cc6a999 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -27,7 +27,7 @@ jobs:
uses: subosito/flutter-action@v2
with:
channel: 'stable'
- flutter-version: '3.41.7' # PINNED: 3.44.0 breaks builtInKotlin=true (KGP force-apply) — see CLAUDE.md
+ flutter-version: '3.44.0'
cache: true
- name: Install dependencies
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 86088e5..cb2f760 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -47,7 +47,7 @@ jobs:
uses: subosito/flutter-action@v2
with:
channel: 'stable'
- flutter-version: '3.41.7' # PINNED: 3.44.0 breaks builtInKotlin=true (KGP force-apply) — see CLAUDE.md
+ flutter-version: '3.44.0'
cache: true
- name: Cache Gradle dependencies
diff --git a/CLAUDE.md b/CLAUDE.md
index cb9b3e3..1b64195 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -20,7 +20,7 @@ This file provides context for AI assistants working on this project.
| Framework | Flutter 3.x |
| Weather API | Open-Meteo (free, no key) |
| Charting | Native SVG (SvgChartGenerator.kt + AndroidSVG) |
-| Widget package | home_widget + native receivers |
+| Widget package | Native AppWidgetProvider + method-channel KV store (`widget_store.dart`) |
| i18n | flutter_localizations + intl (ARB files) |
| License | BSL 1.1 (converts to MIT 2029) |
@@ -48,7 +48,8 @@ lib/
│ └── app_localizations.dart # Generated
├── services/
│ ├── location_service.dart # Geolocator wrapper with fallback
-│ ├── widget_service.dart # home_widget integration
+│ ├── 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)
├── theme/
│ └── app_theme.dart # MeteogramColors, WeatherGradients
@@ -61,7 +62,7 @@ android/app/src/main/
├── kotlin/.../
│ ├── MainActivity.kt
│ ├── MeteogramApplication.kt # Registers receivers, theme observer, alarm
-│ ├── MeteogramWidgetProvider.kt # Extends HomeWidgetProvider
+│ ├── MeteogramWidgetProvider.kt # Extends AppWidgetProvider
│ ├── WidgetEventReceiver.kt # Handles locale/timezone changes
│ ├── WidgetAlarmScheduler.kt # Schedules 15-min inexact alarm
│ ├── WidgetAlarmReceiver.kt # Handles alarm broadcasts
@@ -140,22 +141,31 @@ Android widgets use RemoteViews which only support:
- **Weather fetching**: `WeatherFetcher.kt` calls Open-Meteo API directly (no Dart involved)
- **Material You**: `ContentObserver` + `MaterialYouColorWorker.kt` detect theme changes
-## Before Coding — Flutter is PINNED to 3.41.7
-
-**Do NOT upgrade Flutter past 3.41.7** (local SDK *and* CI). This project uses AGP built-in
-Kotlin (`android.builtInKotlin=true`), which **Flutter 3.44.0 broke**: 3.44.0's Gradle plugin
-force-applies `kotlin-android` to every KGP-less Android module — including transitive Java
-plugins (`jni`/`jni_flutter`, pulled via `home_widget → path_provider`) — and AGP rejects KGP
-under built-in Kotlin. 3.41.7 predates that change (the force-apply landed in 3.44.0; the
-3.41.x branch never got it).
-
-- **Local SDK:** 3.41.7 — set via `flutter downgrade` (from 3.44.0), or `git checkout 3.41.7`
- in the Flutter clone, or FVM per-project.
-- **CI:** pinned via `flutter-version: '3.41.7'` in `.github/workflows/`.
-- **Verify after any toolchain change:** `make analyze` + `make test` + a debug build.
-- **Un-pin when** Flutter stops force-applying KGP to Java-only plugins, OR `home_widget` +
- `path_provider`/`jni` ship built-in-Kotlin builds (track flutter/flutter#185121). Then bump
- Flutter, `flutter pub upgrade`, and re-verify. See `android/gradle.properties` for the flags.
+## Before Coding — AGP-9 built-in Kotlin: keep the plugin set KGP-free
+
+This project runs **AGP-9 built-in Kotlin** (`android.builtInKotlin=true`), and AGP-9 rejects
+the legacy Kotlin Gradle Plugin (KGP) **globally** — a single KGP-applying module breaks the
+build. The project therefore has **zero Flutter plugins with native code**
+(`GeneratedPluginRegistrant` is empty): `home_widget` and `geolocator` were both removed in
+favour of native implementations (`WidgetStore`/`LocationProvider` over the method channel).
+
+**With no KGP plugin present, Flutter 3.44.0 builds clean** (unpinned 2026-05; previously stuck
+on 3.41.7). Flutter 3.44.0 still *tries* to force-apply `kotlin-android` to `:app` and logs a
+benign warning — `Applying the Kotlin Android Plugin (KGP) was unsuccessful. KGP was not found
+on the classpath.` — but the apply is caught (`FlutterPluginUtils.kt:633`) and the build
+succeeds. That one warning line is expected; ignore it (Flutter plans to drop the force-apply,
+flutter/flutter#184837).
+
+- **CRITICAL:** do **not** add any Flutter plugin that applies KGP (most native plugins do) — it
+ will break the build under built-in Kotlin. Prefer a native implementation over the method
+ channel, or a plugin version that supports built-in Kotlin. If a KGP plugin is unavoidable,
+ you must switch to the legacy path: `android.builtInKotlin=false` + re-add `id("kotlin-android")`
+ to `app/build.gradle.kts` (KGP is already declared `apply false` in `settings.gradle.kts`).
+- **CI:** `flutter-version: '3.44.0'` in `.github/workflows/`.
+- **Verify after any toolchain/plugin change:** `make analyze` + `make test` + a debug build.
+ After switching the local Flutter SDK version, run `flutter clean` first (stale kernel caches
+ from a different Dart version cause spurious `dot-shorthands` framework-compile errors).
+- `android.newDsl=false` is still required (removed in AGP 10.0 — a separate future deadline).
## Build Commands
@@ -258,7 +268,7 @@ adb logcat | grep -i "Error inflating"
## Gotchas
1. **RemoteViews errors** - Check logcat for "Class not allowed to be inflated"
-2. **Widget not updating** - Ensure `HomeWidget.updateWidget()` called with correct names
+2. **Widget not updating** - Ensure `WidgetStore.updateWidget()` called with correct provider names
3. **SVG not rendering** - Check logcat for AndroidSVG errors, validate SVG string
4. **Location timeout** - Has 15s timeout with Berlin fallback
5. **Null API values** - Use `?.toDouble() ?? 0.0` pattern for nullable JSON
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 40c2f30..4d2b8a3 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -68,21 +68,6 @@
android:resource="@xml/meteogram_widget_weekly_info" />
-
-
-
-
-
-
-
-
-
-
= Build.VERSION_CODES.P) {
+ lm.isLocationEnabled
+ } else {
+ @Suppress("DEPRECATION")
+ (lm.isProviderEnabled(LocationManager.GPS_PROVIDER) ||
+ lm.isProviderEnabled(LocationManager.NETWORK_PROVIDER))
+ }
+ }
+
+ /** "granted" if fine or coarse location is held, else "denied". */
+ fun checkPermissionStatus(context: Context): String {
+ val fine = context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION)
+ val coarse = context.checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION)
+ val granted = fine == PackageManager.PERMISSION_GRANTED ||
+ coarse == PackageManager.PERMISSION_GRANTED
+ return if (granted) "granted" else "denied"
+ }
+
+ private fun hasPermission(context: Context): Boolean =
+ checkPermissionStatus(context) == "granted"
+
+ /** Most-recent last-known fix across providers, or null. Caller must hold permission. */
+ fun getLastKnownPosition(context: Context): DoubleArray? {
+ if (!hasPermission(context)) return null
+ val lm = context.getSystemService(Context.LOCATION_SERVICE) as? LocationManager ?: return null
+ var best: Location? = null
+ for (provider in PROVIDERS) {
+ try {
+ val loc = lm.getLastKnownLocation(provider) ?: continue
+ if (best == null || loc.time > best.time) best = loc
+ } catch (e: SecurityException) {
+ Log.w(TAG, "No permission for provider $provider", e)
+ } catch (e: IllegalArgumentException) {
+ // Provider doesn't exist on this device — skip.
+ }
+ }
+ return best?.let { doubleArrayOf(it.latitude, it.longitude) }
+ }
+
+ /**
+ * One-shot current-location request (low accuracy). Invokes [callback] exactly
+ * 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
+ if (lm == null) { callback(null); return }
+
+ val provider = PROVIDERS.firstOrNull {
+ try { lm.isProviderEnabled(it) } catch (e: Exception) { false }
+ }
+ if (provider == null) { callback(null); return }
+
+ val handler = Handler(Looper.getMainLooper())
+ var done = false
+
+ val listener = object : LocationListener {
+ override fun onLocationChanged(location: Location) {
+ if (done) return
+ done = true
+ try { lm.removeUpdates(this) } catch (_: Exception) {}
+ 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) {}
+ }
+
+ try {
+ lm.requestLocationUpdates(provider, 0L, 0f, listener, Looper.getMainLooper())
+ } catch (e: SecurityException) {
+ callback(null)
+ return
+ } catch (e: Exception) {
+ Log.w(TAG, "requestLocationUpdates failed", e)
+ callback(null)
+ return
+ }
+
+ handler.postDelayed({
+ if (done) return@postDelayed
+ done = true
+ try { lm.removeUpdates(listener) } catch (_: Exception) {}
+ callback(null)
+ }, timeoutMs)
+ }
+}
diff --git a/android/app/src/main/kotlin/org/bortnik/meteogram/MainActivity.kt b/android/app/src/main/kotlin/org/bortnik/meteogram/MainActivity.kt
index 5a35bb9..b0ca6ab 100644
--- a/android/app/src/main/kotlin/org/bortnik/meteogram/MainActivity.kt
+++ b/android/app/src/main/kotlin/org/bortnik/meteogram/MainActivity.kt
@@ -1,9 +1,14 @@
package org.bortnik.meteogram
+import android.Manifest
+import android.appwidget.AppWidgetManager
+import android.content.ComponentName
import android.content.Intent
+import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.Canvas
import android.net.Uri
+import android.provider.Settings
import android.text.format.DateFormat
import android.util.Log
import com.caverock.androidsvg.SVG
@@ -18,6 +23,10 @@ class MainActivity : FlutterActivity() {
private val CHANNEL = "org.bortnik.meteogram/svg"
private val TAG = "MainActivity"
+ private val LOCATION_PERMISSION_REQUEST_CODE = 4001
+ /** Held while a runtime location-permission request is in flight; resolved in onRequestPermissionsResult. */
+ private var pendingPermissionResult: MethodChannel.Result? = null
+
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
// Extract Material You colors BEFORE Flutter engine starts
// This ensures colors are in storage when Flutter's main() reads them
@@ -135,11 +144,147 @@ class MainActivity : FlutterActivity() {
result.error("OPEN_URL_ERROR", e.message, null)
}
}
+ "saveWidgetData" -> {
+ val id = call.argument("id")
+ if (id == null) {
+ result.error("INVALID_ARGS", "saveWidgetData requires 'id'", null)
+ return@setMethodCallHandler
+ }
+ // Replicates the home_widget plugin's SharedPreferences codec so data
+ // written by earlier (home_widget-backed) installs stays readable: every
+ // key gets a companion "home_widget.double." flag, and doubles are
+ // stored as raw Long bits. See WidgetStore on the Dart side.
+ val editor = getSharedPreferences(WidgetUtils.PREFS_NAME, MODE_PRIVATE).edit()
+ val data = call.argument("data")
+ if (data != null) {
+ editor.putBoolean("home_widget.double.$id", data is Double)
+ when (data) {
+ is Boolean -> editor.putBoolean(id, data)
+ is Float -> editor.putFloat(id, data)
+ is String -> editor.putString(id, data)
+ is Double -> editor.putLong(id, java.lang.Double.doubleToRawLongBits(data))
+ is Int -> editor.putInt(id, data)
+ is Long -> editor.putLong(id, data)
+ else -> {
+ result.error("INVALID_TYPE", "Unsupported type ${data::class.java.simpleName}", null)
+ return@setMethodCallHandler
+ }
+ }
+ } else {
+ editor.remove(id)
+ editor.remove("home_widget.double.$id")
+ }
+ result.success(editor.commit())
+ }
+ "getWidgetData" -> {
+ val id = call.argument("id")
+ if (id == null) {
+ result.error("INVALID_ARGS", "getWidgetData requires 'id'", null)
+ return@setMethodCallHandler
+ }
+ val prefs = getSharedPreferences(WidgetUtils.PREFS_NAME, MODE_PRIVATE)
+ val value = prefs.all[id]
+ if (value is Long && prefs.getBoolean("home_widget.double.$id", false)) {
+ result.success(java.lang.Double.longBitsToDouble(value))
+ } else {
+ result.success(value)
+ }
+ }
+ "updateWidget" -> {
+ val className = call.argument("name") ?: call.argument("android")
+ if (className == null) {
+ result.error("INVALID_ARGS", "updateWidget requires 'name'", null)
+ return@setMethodCallHandler
+ }
+ try {
+ val javaClass = Class.forName("$packageName.$className")
+ val intent = Intent(this, javaClass).apply {
+ action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
+ }
+ val ids = AppWidgetManager.getInstance(applicationContext)
+ .getAppWidgetIds(ComponentName(this, javaClass))
+ intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids)
+ sendBroadcast(intent)
+ result.success(true)
+ } catch (e: ClassNotFoundException) {
+ result.error("NO_WIDGET", "No widget provider named $className", e)
+ }
+ }
+ "isLocationServiceEnabled" -> {
+ result.success(LocationProvider.isLocationServiceEnabled(this))
+ }
+ "checkLocationPermission" -> {
+ result.success(LocationProvider.checkPermissionStatus(this))
+ }
+ "requestLocationPermission" -> {
+ if (LocationProvider.checkPermissionStatus(this) == "granted") {
+ result.success("granted")
+ return@setMethodCallHandler
+ }
+ if (pendingPermissionResult != null) {
+ result.error("PERMISSION_IN_PROGRESS", "A location permission request is already in progress", null)
+ return@setMethodCallHandler
+ }
+ pendingPermissionResult = result
+ requestPermissions(
+ arrayOf(
+ Manifest.permission.ACCESS_FINE_LOCATION,
+ Manifest.permission.ACCESS_COARSE_LOCATION
+ ),
+ LOCATION_PERMISSION_REQUEST_CODE
+ )
+ }
+ "getCurrentPosition" -> {
+ val timeoutMs = (call.argument("timeoutMs") ?: 15000).toLong()
+ LocationProvider.getCurrentPosition(this, timeoutMs) { coords ->
+ runOnUiThread {
+ result.success(coords?.let { mapOf("latitude" to it[0], "longitude" to it[1]) })
+ }
+ }
+ }
+ "getLastKnownPosition" -> {
+ val coords = LocationProvider.getLastKnownPosition(this)
+ result.success(coords?.let { mapOf("latitude" to it[0], "longitude" to it[1]) })
+ }
+ "openLocationSettings" -> {
+ try {
+ val intent = Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ startActivity(intent)
+ result.success(true)
+ } catch (e: Exception) {
+ Log.e(TAG, "Error opening location settings", e)
+ result.error("OPEN_SETTINGS_ERROR", e.message, null)
+ }
+ }
else -> result.notImplemented()
}
}
}
+ override fun onRequestPermissionsResult(
+ requestCode: Int,
+ permissions: Array,
+ grantResults: IntArray
+ ) {
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults)
+ if (requestCode != LOCATION_PERMISSION_REQUEST_CODE) return
+
+ val pending = pendingPermissionResult
+ pendingPermissionResult = null
+ if (pending == null) return
+
+ val granted = grantResults.isNotEmpty() &&
+ grantResults.any { it == PackageManager.PERMISSION_GRANTED }
+ val status = when {
+ granted -> "granted"
+ // Not granted and no rationale allowed → user chose "don't ask again" (or it's policy-blocked).
+ !shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_FINE_LOCATION) -> "deniedForever"
+ else -> "denied"
+ }
+ pending.success(status)
+ }
+
/**
* Generate SVG string from cached weather data.
* Also updates current_temperature_celsius in SharedPreferences to match nowIndex.
diff --git a/android/app/src/main/kotlin/org/bortnik/meteogram/MeteogramWidgetProvider.kt b/android/app/src/main/kotlin/org/bortnik/meteogram/MeteogramWidgetProvider.kt
index 27103e1..4b3907d 100644
--- a/android/app/src/main/kotlin/org/bortnik/meteogram/MeteogramWidgetProvider.kt
+++ b/android/app/src/main/kotlin/org/bortnik/meteogram/MeteogramWidgetProvider.kt
@@ -2,6 +2,7 @@ package org.bortnik.meteogram
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
+import android.appwidget.AppWidgetProvider
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
@@ -18,7 +19,6 @@ import android.view.ContextThemeWrapper
import android.view.View
import android.widget.RemoteViews
import com.caverock.androidsvg.SVG
-import es.antonborri.home_widget.HomeWidgetProvider
import java.io.ByteArrayInputStream
import java.io.File
import java.io.FileInputStream
@@ -30,7 +30,7 @@ import java.util.Locale
* extension points to change layout, time range, or time labels without
* re-implementing the full RemoteViews update cycle.
*/
-open class MeteogramWidgetProvider : HomeWidgetProvider() {
+open class MeteogramWidgetProvider : AppWidgetProvider() {
/** Layout resource used for this widget's RemoteViews. */
protected open val layoutRes: Int = R.layout.meteogram_widget
@@ -327,9 +327,10 @@ open class MeteogramWidgetProvider : HomeWidgetProvider() {
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
- appWidgetIds: IntArray,
- widgetData: SharedPreferences
+ appWidgetIds: IntArray
) {
+ // Open the shared store directly (previously supplied by HomeWidgetProvider).
+ val widgetData = context.getSharedPreferences(WidgetUtils.PREFS_NAME, Context.MODE_PRIVATE)
Log.d(logTag, "onUpdate called for ${appWidgetIds.size} widgets: ${appWidgetIds.joinToString()}")
updateWidgetIdsList(context, appWidgetIds)
diff --git a/android/gradle.properties b/android/gradle.properties
index c39eb1c..af16bb8 100644
--- a/android/gradle.properties
+++ b/android/gradle.properties
@@ -2,12 +2,13 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m
android.useAndroidX=true
# AGP 9 built-in Kotlin: no module applies the Kotlin Gradle Plugin (kotlin-android),
-# so AGP compiles Kotlin directly — this clears the "applies KGP" deprecation warning.
-# builtInKotlin defaults to true on AGP 9; set explicitly to document intent.
+# so AGP compiles Kotlin directly. The project keeps zero KGP-using plugins (home_widget +
+# geolocator went native) so this works under Flutter 3.44.0. builtInKotlin defaults to true
+# on AGP 9; set explicitly to document intent. Do NOT add a plugin that applies KGP — see CLAUDE.md.
#
-# newDsl is kept false because Flutter 3.41.7's Gradle plugin cannot apply under the new
-# DSL (it throws). This opt-out is removed in AGP 10.0 — by then we'll need a Flutter
-# whose Gradle plugin supports android.newDsl=true (then drop this line + restore the
-# new-DSL subprojects config in build.gradle.kts).
+# newDsl is kept false because the Flutter Gradle plugin path we use does not support the new
+# DSL yet (it throws). This opt-out is removed in AGP 10.0 — by then we'll need a Flutter whose
+# Gradle plugin supports android.newDsl=true (then drop this line + restore the new-DSL
+# subprojects config in build.gradle.kts).
android.builtInKotlin=true
android.newDsl=false
diff --git a/docs/ai/architecture.md b/docs/ai/architecture.md
index 01fb074..f7c5108 100644
--- a/docs/ai/architecture.md
+++ b/docs/ai/architecture.md
@@ -137,10 +137,13 @@ All city names are localized via reverse geocoding based on device locale.
- Debounced search (300ms) in UI
### WidgetService (`lib/services/widget_service.dart`)
-Home widget data management. Responsibilities:
-- Save chart image to app documents folder
-- Save widget data via HomeWidget.saveWidgetData
-- Trigger native widget update
+Home widget refresh coordination. Responsibilities:
+- Trigger native widget update for all provider variants (via `WidgetStore.updateWidget`)
+- Check/clear the resize flag
+
+Shared key-value storage (the `HomeWidgetPreferences` SharedPreferences file) is accessed
+through `WidgetStore` (`lib/services/widget_store.dart`), a method-channel bridge to native
+Kotlin that replaced the `home_widget` package.
### Native Background Updates
All background updates are handled natively in Kotlin (no Dart/Flutter involved):
@@ -204,7 +207,7 @@ scripts/
## Platform-Specific
### Android Widget
-- `MeteogramWidgetProvider` extends `HomeWidgetProvider`
+- `MeteogramWidgetProvider` extends `AppWidgetProvider`
- Layout in `res/layout/meteogram_widget.xml`
- Uses RemoteViews (limited to TextView, ImageView, LinearLayout, RelativeLayout, FrameLayout)
- Background in `res/drawable/widget_background.xml` (gradient + rounded corners)
@@ -247,7 +250,7 @@ catch (e) {
### Flutter/Dart
| Package | Purpose |
|---------|---------|
-| home_widget | Flutter ↔ native widget bridge |
+| (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) |
diff --git a/docs/ai/widget.md b/docs/ai/widget.md
index e2f3ada..8510a30 100644
--- a/docs/ai/widget.md
+++ b/docs/ai/widget.md
@@ -2,7 +2,16 @@
## Overview
-Using the `home_widget` package with native Android widget provider. The chart is generated as SVG natively in Kotlin (`SvgChartGenerator.kt`), then rendered using AndroidSVG library. Both the widget and the in-app chart use the same native SVG generation for consistency.
+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.
+
+> **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.
See `docs/NATIVE_SVG_RENDERING.md` for detailed architecture.
diff --git a/lib/main.dart b/lib/main.dart
index 71712ce..71b4689 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -16,8 +16,6 @@ void main() async {
// Enable edge-to-edge mode (required for Android 15+)
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
- await WidgetService.initialize();
-
// Load Material You colors from native Android code
final materialYouColors = await MaterialYouService.getColors();
diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart
index 69a9f98..a0696f8 100644
--- a/lib/screens/home_screen.dart
+++ b/lib/screens/home_screen.dart
@@ -1,7 +1,6 @@
import 'dart:async';
import 'dart:ui' show PlatformDispatcher;
import 'package:flutter/material.dart';
-import 'package:home_widget/home_widget.dart';
import '../constants.dart';
import '../l10n/app_localizations.dart';
import '../services/location_service.dart';
@@ -9,6 +8,7 @@ import '../services/widget_service.dart';
import '../services/native_svg_service.dart';
import '../services/units_service.dart';
import '../services/material_you_service.dart';
+import '../services/widget_store.dart';
import '../theme/app_theme.dart';
import '../widgets/native_svg_chart_view.dart';
import '../generated/version.dart';
@@ -91,15 +91,15 @@ class _HomeScreenState extends State with WidgetsBindingObserver {
await _initializeData();
}
- /// Save locale and units preferences to HomeWidget for background service.
+ /// Save locale and units preferences to the shared widget store for background service.
/// Called early in initialization so background can use correct settings.
Future _saveLocaleAndUnits() async {
final platformLocale = PlatformDispatcher.instance.locale;
final locale = platformLocale.toString();
final usesFahrenheit = UnitsService.usesFahrenheit(platformLocale);
- await HomeWidget.saveWidgetData('locale', locale);
- await HomeWidget.saveWidgetData('usesFahrenheit', usesFahrenheit);
+ await WidgetStore.saveWidgetData('locale', locale);
+ await WidgetStore.saveWidgetData('usesFahrenheit', usesFahrenheit);
debugPrint('Saved locale/units at startup: $locale, usesFahrenheit=$usesFahrenheit');
}
diff --git a/lib/services/location_bridge.dart b/lib/services/location_bridge.dart
new file mode 100644
index 0000000..63e020a
--- /dev/null
+++ b/lib/services/location_bridge.dart
@@ -0,0 +1,98 @@
+import 'package:flutter/foundation.dart';
+import 'package:flutter/services.dart';
+
+/// Location-permission state (the subset this app needs).
+enum LocationPermissionStatus { granted, denied, deniedForever }
+
+/// Native location access over the method channel — replaces the geolocator
+/// plugin. Backed by `android.location.LocationManager` (see `LocationProvider.kt`).
+///
+/// All fixes are foreground-only; the widget's background refresh reuses cached
+/// coordinates and never calls these.
+class LocationBridge {
+ LocationBridge._();
+
+ static const _channel = MethodChannel('org.bortnik.meteogram/svg');
+
+ /// Whether device location services (GPS/network) are enabled.
+ static Future isLocationServiceEnabled() async {
+ try {
+ return await _channel.invokeMethod('isLocationServiceEnabled') ?? false;
+ } on PlatformException catch (e) {
+ debugPrint('isLocationServiceEnabled failed: ${e.message}');
+ return false;
+ }
+ }
+
+ /// Current location-permission status (no prompt shown).
+ static Future checkPermission() async {
+ try {
+ return _parse(await _channel.invokeMethod('checkLocationPermission'));
+ } on PlatformException catch (e) {
+ debugPrint('checkLocationPermission failed: ${e.message}');
+ return LocationPermissionStatus.denied;
+ }
+ }
+
+ /// Prompt for location permission; resolves once the user responds.
+ static Future requestPermission() async {
+ try {
+ return _parse(await _channel.invokeMethod('requestLocationPermission'));
+ } on PlatformException catch (e) {
+ debugPrint('requestLocationPermission failed: ${e.message}');
+ return LocationPermissionStatus.denied;
+ }
+ }
+
+ /// One-shot current location (low accuracy). Null on timeout/failure.
+ static Future<({double latitude, double longitude})?> getCurrentPosition({
+ int timeoutMs = 15000,
+ }) async {
+ try {
+ return _toPosition(await _channel
+ .invokeMapMethod('getCurrentPosition', {'timeoutMs': timeoutMs}));
+ } on PlatformException catch (e) {
+ debugPrint('getCurrentPosition failed: ${e.message}');
+ return null;
+ }
+ }
+
+ /// Most-recent last-known location, or null.
+ static Future<({double latitude, double longitude})?> getLastKnownPosition() async {
+ try {
+ return _toPosition(
+ await _channel.invokeMapMethod('getLastKnownPosition'));
+ } on PlatformException catch (e) {
+ debugPrint('getLastKnownPosition failed: ${e.message}');
+ return null;
+ }
+ }
+
+ /// Open the system location-settings screen.
+ static Future openLocationSettings() async {
+ try {
+ await _channel.invokeMethod('openLocationSettings');
+ } on PlatformException catch (e) {
+ debugPrint('openLocationSettings failed: ${e.message}');
+ }
+ }
+
+ static LocationPermissionStatus _parse(String? status) {
+ switch (status) {
+ case 'granted':
+ return LocationPermissionStatus.granted;
+ case 'deniedForever':
+ return LocationPermissionStatus.deniedForever;
+ default:
+ return LocationPermissionStatus.denied;
+ }
+ }
+
+ static ({double latitude, double longitude})? _toPosition(Map? r) {
+ if (r == null) return null;
+ final lat = (r['latitude'] as num?)?.toDouble();
+ final lon = (r['longitude'] as num?)?.toDouble();
+ if (lat == null || lon == null) return null;
+ return (latitude: lat, longitude: lon);
+ }
+}
diff --git a/lib/services/location_service.dart b/lib/services/location_service.dart
index 4b64574..931e781 100644
--- a/lib/services/location_service.dart
+++ b/lib/services/location_service.dart
@@ -1,9 +1,9 @@
import 'dart:convert';
-import 'package:geolocator_platform_interface/geolocator_platform_interface.dart';
-import 'package:home_widget/home_widget.dart';
import 'package:http/http.dart' as http;
+import 'location_bridge.dart';
import 'native_svg_service.dart';
+import 'widget_store.dart';
/// Default fallback location (Berlin) when GPS is unavailable.
const double kDefaultLatitude = 52.52;
@@ -26,7 +26,7 @@ class LocationService {
/// Get the current location (GPS or saved).
Future getLocation() async {
- final useGps = await HomeWidget.getWidgetData(_useGpsKey) ?? true;
+ final useGps = await WidgetStore.getWidgetData(_useGpsKey) ?? true;
if (useGps) {
return _getGpsLocation();
}
@@ -37,76 +37,56 @@ class LocationService {
/// Get location from GPS, with fallback to default location (Berlin).
Future _getGpsLocation() async {
// Check if location services are enabled
- final serviceEnabled = await GeolocatorPlatform.instance.isLocationServiceEnabled();
+ final serviceEnabled = await LocationBridge.isLocationServiceEnabled();
if (!serviceEnabled) {
return _getFallbackLocation();
}
- // Check permission
- var permission = await GeolocatorPlatform.instance.checkPermission();
- if (permission == LocationPermission.denied) {
- permission = await GeolocatorPlatform.instance.requestPermission();
- if (permission == LocationPermission.denied) {
- return _getFallbackLocation();
- }
+ // Check permission, prompting once if it's merely denied
+ var permission = await LocationBridge.checkPermission();
+ if (permission == LocationPermissionStatus.denied) {
+ permission = await LocationBridge.requestPermission();
}
-
- if (permission == LocationPermission.deniedForever) {
+ if (permission != LocationPermissionStatus.granted) {
+ // denied or deniedForever
return _getFallbackLocation();
}
- // Get position with timeout
- try {
- final position = await GeolocatorPlatform.instance.getCurrentPosition(
- locationSettings: const LocationSettings(
- accuracy: LocationAccuracy.low,
- timeLimit: Duration(seconds: 15),
- ),
- ).timeout(const Duration(seconds: 15));
-
- // Get city name via reverse geocoding
- String? city = await _getCityFromCoordinates(
- position.latitude,
- position.longitude,
- );
- // Fallback: derive city name from coordinates for known test locations
- if (city == null || city.isEmpty) {
- city = _getTestCityName(position.latitude, position.longitude);
- }
+ // Current position (native one-shot, low accuracy, 15s timeout). Returns null
+ // on timeout/failure rather than throwing.
+ final position = await LocationBridge.getCurrentPosition(timeoutMs: 15000);
+ if (position != null) {
+ return _toLocationData(position.latitude, position.longitude);
+ }
- return LocationData(
- latitude: position.latitude,
- longitude: position.longitude,
- source: LocationSource.gps,
- city: city,
- );
- } catch (e) {
- // Fallback to last known position
- final lastPosition = await GeolocatorPlatform.instance.getLastKnownPosition();
- if (lastPosition != null) {
- // Try to get city name for last known position
- String? city = await _getCityFromCoordinates(
- lastPosition.latitude,
- lastPosition.longitude,
- );
- if (city == null || city.isEmpty) {
- city = _getTestCityName(lastPosition.latitude, lastPosition.longitude);
- }
- return LocationData(
- latitude: lastPosition.latitude,
- longitude: lastPosition.longitude,
- source: LocationSource.gps,
- city: city,
- );
- }
- // Final fallback to default location
- return LocationData(
- latitude: kDefaultLatitude,
- longitude: kDefaultLongitude,
- source: LocationSource.manual,
- city: kDefaultCity,
- );
+ // Fallback to last known position
+ final lastPosition = await LocationBridge.getLastKnownPosition();
+ if (lastPosition != null) {
+ return _toLocationData(lastPosition.latitude, lastPosition.longitude);
}
+
+ // Final fallback to default location
+ return LocationData(
+ latitude: kDefaultLatitude,
+ longitude: kDefaultLongitude,
+ source: LocationSource.manual,
+ city: kDefaultCity,
+ );
+ }
+
+ /// Build a GPS [LocationData], resolving a city name via reverse geocoding
+ /// (with a test-location fallback for the emulator/Berlin).
+ Future _toLocationData(double latitude, double longitude) async {
+ String? city = await _getCityFromCoordinates(latitude, longitude);
+ if (city == null || city.isEmpty) {
+ city = _getTestCityName(latitude, longitude);
+ }
+ return LocationData(
+ latitude: latitude,
+ longitude: longitude,
+ source: LocationSource.gps,
+ city: city,
+ );
}
/// Get fallback location when GPS is unavailable.
@@ -154,40 +134,40 @@ class LocationService {
String? city,
LocationSource source = LocationSource.manual,
}) async {
- await HomeWidget.saveWidgetData(_latKey, latitude);
- await HomeWidget.saveWidgetData(_lonKey, longitude);
+ await WidgetStore.saveWidgetData(_latKey, latitude);
+ await WidgetStore.saveWidgetData(_lonKey, longitude);
if (city != null) {
- await HomeWidget.saveWidgetData(_cityKey, city);
+ await WidgetStore.saveWidgetData(_cityKey, city);
}
- await HomeWidget.saveWidgetData(_sourceKey, source.name);
- await HomeWidget.saveWidgetData(_useGpsKey, false);
+ await WidgetStore.saveWidgetData(_sourceKey, source.name);
+ await WidgetStore.saveWidgetData(_useGpsKey, false);
}
/// Switch to using GPS location.
/// Persisted to the shared widget store (read by both the app and the native widget).
Future useGpsLocation() async {
- await HomeWidget.saveWidgetData(_useGpsKey, true);
- await HomeWidget.saveWidgetData(_sourceKey, LocationSource.gps.name);
+ await WidgetStore.saveWidgetData(_useGpsKey, true);
+ await WidgetStore.saveWidgetData(_sourceKey, LocationSource.gps.name);
}
/// Check if using GPS.
Future isUsingGps() async {
- return await HomeWidget.getWidgetData(_useGpsKey) ?? true;
+ return await WidgetStore.getWidgetData(_useGpsKey) ?? true;
}
- /// Get saved location from HomeWidget storage (for background service).
+ /// Get saved location from the shared widget store (for background service).
/// Returns null if no location is saved or if using GPS.
/// This is more reliable than SharedPreferences in background isolates.
Future getSavedLocationFromWidget() async {
- final useGps = await HomeWidget.getWidgetData(_useGpsKey) ?? true;
+ final useGps = await WidgetStore.getWidgetData(_useGpsKey) ?? true;
if (useGps) {
return null; // GPS mode, no saved location
}
- final lat = await HomeWidget.getWidgetData(_latKey);
- final lon = await HomeWidget.getWidgetData(_lonKey);
- final city = await HomeWidget.getWidgetData(_cityKey);
- final sourceName = await HomeWidget.getWidgetData(_sourceKey);
+ final lat = await WidgetStore.getWidgetData(_latKey);
+ final lon = await WidgetStore.getWidgetData(_lonKey);
+ final city = await WidgetStore.getWidgetData(_cityKey);
+ final sourceName = await WidgetStore.getWidgetData(_sourceKey);
if (lat == null || lon == null) {
return null;
@@ -210,24 +190,23 @@ class LocationService {
/// Returns true if permission is granted.
Future requestGpsPermission() async {
// Check if location services are enabled
- final serviceEnabled = await GeolocatorPlatform.instance.isLocationServiceEnabled();
+ final serviceEnabled = await LocationBridge.isLocationServiceEnabled();
if (!serviceEnabled) {
return false;
}
// Check and request permission
- var permission = await GeolocatorPlatform.instance.checkPermission();
- if (permission == LocationPermission.denied) {
- permission = await GeolocatorPlatform.instance.requestPermission();
+ var permission = await LocationBridge.checkPermission();
+ if (permission == LocationPermissionStatus.denied) {
+ permission = await LocationBridge.requestPermission();
}
- return permission == LocationPermission.always ||
- permission == LocationPermission.whileInUse;
+ return permission == LocationPermissionStatus.granted;
}
/// Open device location settings.
Future openLocationSettings() async {
- await GeolocatorPlatform.instance.openLocationSettings();
+ await LocationBridge.openLocationSettings();
}
/// Search for cities using Open-Meteo geocoding API.
@@ -354,7 +333,7 @@ class LocationService {
/// Returns [] if the stored data is missing or malformed (rather than throwing),
/// and silently skips any individual entries that don't parse.
Future> getRecentCities() async {
- final raw = await HomeWidget.getWidgetData(_recentCitiesKey);
+ final raw = await WidgetStore.getWidgetData(_recentCitiesKey);
if (raw == null || raw.isEmpty) return [];
try {
final decoded = jsonDecode(raw);
@@ -389,7 +368,7 @@ class LocationService {
// Keep only last 5
final trimmed = cities.take(5).toList();
- await HomeWidget.saveWidgetData(
+ await WidgetStore.saveWidgetData(
_recentCitiesKey,
jsonEncode(trimmed.map((c) => c.toJson()).toList()),
);
diff --git a/lib/services/material_you_service.dart b/lib/services/material_you_service.dart
index 835611f..d99de1f 100644
--- a/lib/services/material_you_service.dart
+++ b/lib/services/material_you_service.dart
@@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
-import 'package:home_widget/home_widget.dart';
+import 'widget_store.dart';
/// Service to read Material You colors extracted by native Android code.
///
@@ -27,7 +27,7 @@ class MaterialYouService {
/// Returns null if colors are not available (Android < 12 or not yet extracted).
static Future getColors() async {
// Read from HomeWidgetPreferences (same file as native Kotlin code)
- final lightPrimary = await HomeWidget.getWidgetData(_keyLightPrimary);
+ final lightPrimary = await WidgetStore.getWidgetData(_keyLightPrimary);
// Check if colors have been extracted by native code
if (lightPrimary == null) {
@@ -37,21 +37,21 @@ class MaterialYouService {
return MaterialYouColors(
light: MaterialYouThemeColors(
primary: Color(lightPrimary),
- onPrimaryContainer: Color(await HomeWidget.getWidgetData(_keyLightOnPrimaryContainer) ?? lightPrimary),
- tertiary: Color(await HomeWidget.getWidgetData(_keyLightTertiary) ?? lightPrimary),
- surface: Color(await HomeWidget.getWidgetData(_keyLightSurface) ?? 0xFFFAFAFA),
- surfaceContainer: Color(await HomeWidget.getWidgetData(_keyLightSurfaceContainer) ?? 0xFFEEEEEE),
- surfaceContainerHigh: Color(await HomeWidget.getWidgetData(_keyLightSurfaceContainerHigh) ?? 0xFFE0E0E0),
- onSurface: Color(await HomeWidget.getWidgetData(_keyLightOnSurface) ?? 0xFF1C1C1C),
+ onPrimaryContainer: Color(await WidgetStore.getWidgetData(_keyLightOnPrimaryContainer) ?? lightPrimary),
+ tertiary: Color(await WidgetStore.getWidgetData(_keyLightTertiary) ?? lightPrimary),
+ surface: Color(await WidgetStore.getWidgetData(_keyLightSurface) ?? 0xFFFAFAFA),
+ surfaceContainer: Color(await WidgetStore.getWidgetData(_keyLightSurfaceContainer) ?? 0xFFEEEEEE),
+ surfaceContainerHigh: Color(await WidgetStore.getWidgetData(_keyLightSurfaceContainerHigh) ?? 0xFFE0E0E0),
+ onSurface: Color(await WidgetStore.getWidgetData(_keyLightOnSurface) ?? 0xFF1C1C1C),
),
dark: MaterialYouThemeColors(
- primary: Color(await HomeWidget.getWidgetData(_keyDarkPrimary) ?? 0xFF90CAF9),
- onPrimaryContainer: Color(await HomeWidget.getWidgetData(_keyDarkOnPrimaryContainer) ?? 0xFFE3F2FD),
- tertiary: Color(await HomeWidget.getWidgetData(_keyDarkTertiary) ?? 0xFF80CBC4),
- surface: Color(await HomeWidget.getWidgetData(_keyDarkSurface) ?? 0xFF121212),
- surfaceContainer: Color(await HomeWidget.getWidgetData(_keyDarkSurfaceContainer) ?? 0xFF1E1E1E),
- surfaceContainerHigh: Color(await HomeWidget.getWidgetData(_keyDarkSurfaceContainerHigh) ?? 0xFF2D2D2D),
- onSurface: Color(await HomeWidget.getWidgetData(_keyDarkOnSurface) ?? 0xFFE0E0E0),
+ primary: Color(await WidgetStore.getWidgetData(_keyDarkPrimary) ?? 0xFF90CAF9),
+ onPrimaryContainer: Color(await WidgetStore.getWidgetData(_keyDarkOnPrimaryContainer) ?? 0xFFE3F2FD),
+ tertiary: Color(await WidgetStore.getWidgetData(_keyDarkTertiary) ?? 0xFF80CBC4),
+ surface: Color(await WidgetStore.getWidgetData(_keyDarkSurface) ?? 0xFF121212),
+ surfaceContainer: Color(await WidgetStore.getWidgetData(_keyDarkSurfaceContainer) ?? 0xFF1E1E1E),
+ surfaceContainerHigh: Color(await WidgetStore.getWidgetData(_keyDarkSurfaceContainerHigh) ?? 0xFF2D2D2D),
+ onSurface: Color(await WidgetStore.getWidgetData(_keyDarkOnSurface) ?? 0xFFE0E0E0),
),
);
}
diff --git a/lib/services/native_svg_service.dart b/lib/services/native_svg_service.dart
index 1b25716..24f6d0e 100644
--- a/lib/services/native_svg_service.dart
+++ b/lib/services/native_svg_service.dart
@@ -1,6 +1,6 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
-import 'package:home_widget/home_widget.dart';
+import 'widget_store.dart';
/// Service for native Kotlin operations via method channel.
/// Handles SVG generation, weather fetching, and cache management.
@@ -74,16 +74,16 @@ class NativeSvgService {
/// Get current temperature in Celsius (saved by Kotlin when weather is fetched).
static Future getCurrentTemperatureCelsius() async {
- // Kotlin stores as string for home_widget compatibility
- final tempStr = await HomeWidget.getWidgetData(_keyCurrentTempCelsius);
+ // Native side stores this as a string; WidgetStore returns it verbatim
+ final tempStr = await WidgetStore.getWidgetData(_keyCurrentTempCelsius);
if (tempStr == null) return null;
return double.tryParse(tempStr);
}
/// Get timestamp of last weather update.
static Future getLastWeatherUpdate() async {
- // Kotlin stores as string for home_widget compatibility
- final lastUpdateStr = await HomeWidget.getWidgetData(_keyLastWeatherUpdate);
+ // Native side stores this as a string; WidgetStore returns it verbatim
+ final lastUpdateStr = await WidgetStore.getWidgetData(_keyLastWeatherUpdate);
if (lastUpdateStr == null) return null;
final lastUpdate = int.tryParse(lastUpdateStr);
if (lastUpdate == null) return null;
@@ -92,24 +92,24 @@ class NativeSvgService {
/// Check if we have cached weather data.
static Future hasWeatherData() async {
- final lastUpdateStr = await HomeWidget.getWidgetData(_keyLastWeatherUpdate);
+ final lastUpdateStr = await WidgetStore.getWidgetData(_keyLastWeatherUpdate);
return lastUpdateStr != null;
}
/// Get cached city name.
static Future getCachedCityName() async {
- return HomeWidget.getWidgetData(_keyCachedCityName);
+ return WidgetStore.getWidgetData(_keyCachedCityName);
}
/// Get cached location source.
static Future getCachedLocationSource() async {
- return HomeWidget.getWidgetData(_keyCachedLocationSource);
+ return WidgetStore.getWidgetData(_keyCachedLocationSource);
}
/// Check if cached data is stale (older than 15 minutes).
static Future isCacheStale() async {
- // Kotlin stores as string for home_widget compatibility
- final lastUpdateStr = await HomeWidget.getWidgetData(_keyLastWeatherUpdate);
+ // Native side stores this as a string; WidgetStore returns it verbatim
+ final lastUpdateStr = await WidgetStore.getWidgetData(_keyLastWeatherUpdate);
if (lastUpdateStr == null) return true;
final lastUpdate = int.tryParse(lastUpdateStr);
if (lastUpdate == null) return true;
@@ -123,10 +123,10 @@ class NativeSvgService {
/// Save location info to cache for offline display.
static Future cacheLocationInfo(String? cityName, String? locationSource) async {
if (cityName != null) {
- await HomeWidget.saveWidgetData(_keyCachedCityName, cityName);
+ await WidgetStore.saveWidgetData(_keyCachedCityName, cityName);
}
if (locationSource != null) {
- await HomeWidget.saveWidgetData(_keyCachedLocationSource, locationSource);
+ await WidgetStore.saveWidgetData(_keyCachedLocationSource, locationSource);
}
}
diff --git a/lib/services/theme_service.dart b/lib/services/theme_service.dart
index 4c3e211..d50f490 100644
--- a/lib/services/theme_service.dart
+++ b/lib/services/theme_service.dart
@@ -1,10 +1,10 @@
import 'package:flutter/material.dart';
-import 'package:home_widget/home_widget.dart';
+import 'widget_store.dart';
/// Persists the user's in-app theme preference (System / Light / Dark).
///
/// The choice drives both the in-app UI and the home-screen widget, so it is
-/// stored in [HomeWidget] storage (`HomeWidgetPreferences`) — the same store the
+/// stored in [WidgetStore] (`HomeWidgetPreferences`) — the same store the
/// native widget provider reads to match the app's theme.
class ThemeService {
/// Storage key holding the serialized [ThemeMode].
@@ -16,12 +16,12 @@ class ThemeService {
/// Loads the saved theme mode, defaulting to [ThemeMode.system].
Future load() async {
- return _fromString(await HomeWidget.getWidgetData(_prefKey));
+ return _fromString(await WidgetStore.getWidgetData(_prefKey));
}
/// Persists [mode] for future launches in the shared widget store.
Future save(ThemeMode mode) async {
- await HomeWidget.saveWidgetData(_prefKey, _toString(mode));
+ await WidgetStore.saveWidgetData(_prefKey, _toString(mode));
}
static ThemeMode _fromString(String? value) {
diff --git a/lib/services/widget_service.dart b/lib/services/widget_service.dart
index 944f952..a0d70b6 100644
--- a/lib/services/widget_service.dart
+++ b/lib/services/widget_service.dart
@@ -1,5 +1,5 @@
import 'package:flutter/foundation.dart';
-import 'package:home_widget/home_widget.dart';
+import 'widget_store.dart';
/// Service for updating the home screen widget.
///
@@ -14,30 +14,24 @@ class WidgetService {
/// The native code will generate SVGs from cached weather data.
Future triggerWidgetUpdate() async {
try {
- await HomeWidget.updateWidget(
+ await WidgetStore.updateWidget(
androidName: _androidWidgetName,
iOSName: _iosWidgetName,
);
- await HomeWidget.updateWidget(androidName: _androidWeeklyWidgetName);
+ await WidgetStore.updateWidget(androidName: _androidWeeklyWidgetName);
debugPrint('Triggered native widget update');
} catch (e) {
debugPrint('Error triggering widget update: $e');
}
}
- /// Initialize widget service.
- static Future initialize() async {
- // Set app group ID for iOS
- await HomeWidget.setAppGroupId('group.org.bortnik.meteogram');
- }
-
/// Check if widget was resized and clear the flag.
/// Returns true if widget needs re-rendering.
Future checkAndClearResizeFlag() async {
try {
- final resized = await HomeWidget.getWidgetData('widget_resized');
+ final resized = await WidgetStore.getWidgetData('widget_resized');
if (resized == true) {
- await HomeWidget.saveWidgetData('widget_resized', false);
+ await WidgetStore.saveWidgetData('widget_resized', false);
debugPrint('Widget was resized');
return true;
}
diff --git a/lib/services/widget_store.dart b/lib/services/widget_store.dart
new file mode 100644
index 0000000..b9c49d0
--- /dev/null
+++ b/lib/services/widget_store.dart
@@ -0,0 +1,38 @@
+import 'package:flutter/services.dart';
+
+/// Key-value bridge to the shared `HomeWidgetPreferences` SharedPreferences file,
+/// plus a widget-refresh trigger. Replaces the `home_widget` package's storage +
+/// `updateWidget` surface (the only parts this app used) over our existing native
+/// method channel — see [MainActivity] `getWidgetData`/`saveWidgetData`/`updateWidget`.
+///
+/// The native side replicates `home_widget`'s exact serialization (a companion
+/// `home_widget.double.` flag and `doubleToRawLongBits` for doubles), so data
+/// written by earlier `home_widget`-backed installs is preserved across upgrade.
+class WidgetStore {
+ WidgetStore._();
+
+ static const _channel = MethodChannel('org.bortnik.meteogram/svg');
+
+ /// Reads the value stored under [key], or null if absent.
+ static Future getWidgetData(String key) {
+ return _channel.invokeMethod('getWidgetData', {'id': key});
+ }
+
+ /// Persists [value] under [key]. A null value removes the key.
+ /// Returns true if the write committed successfully.
+ static Future saveWidgetData(String key, T? value) async {
+ final committed = await _channel.invokeMethod('saveWidgetData', {
+ 'id': key,
+ 'data': value,
+ });
+ return committed ?? false;
+ }
+
+ /// Triggers a native refresh of the given Android widget provider by sending
+ /// an `ACTION_APPWIDGET_UPDATE` broadcast. [iOSName] is accepted for call-site
+ /// compatibility and ignored (this is an Android-only app).
+ static Future updateWidget({String? androidName, String? iOSName}) async {
+ if (androidName == null) return;
+ await _channel.invokeMethod('updateWidget', {'name': androidName});
+ }
+}
diff --git a/pubspec.lock b/pubspec.lock
index d8014d8..4f1b0b3 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -1,14 +1,6 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
- args:
- dependency: transitive
- description:
- name: args
- sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
- url: "https://pub.dev"
- source: hosted
- version: "2.7.0"
async:
dependency: transitive
description:
@@ -41,14 +33,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.2"
- code_assets:
- dependency: transitive
- description:
- name: code_assets
- sha256: "67cf6d84013f9c601e42a6f8a6b74c4c0d9dc1a1619d775f2b28b732d3551b85"
- url: "https://pub.dev"
- source: hosted
- version: "1.2.0"
collection:
dependency: transitive
description:
@@ -57,14 +41,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.19.1"
- crypto:
- dependency: transitive
- description:
- name: crypto
- sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
- url: "https://pub.dev"
- source: hosted
- version: "3.0.7"
fake_async:
dependency: transitive
description:
@@ -73,22 +49,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.3"
- ffi:
- dependency: transitive
- description:
- name: ffi
- sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
- url: "https://pub.dev"
- source: hosted
- version: "2.2.0"
- fixnum:
- dependency: transitive
- description:
- name: fixnum
- sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
- url: "https://pub.dev"
- source: hosted
- version: "1.1.1"
flutter:
dependency: "direct main"
description: flutter
@@ -112,38 +72,6 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
- geolocator_android:
- dependency: "direct main"
- description:
- name: geolocator_android
- sha256: "179c3cb66dfa674fc9ccbf2be872a02658724d1c067634e2c427cf6df7df901a"
- url: "https://pub.dev"
- source: hosted
- version: "5.0.2"
- geolocator_platform_interface:
- dependency: "direct main"
- description:
- name: geolocator_platform_interface
- sha256: "30cb64f0b9adcc0fb36f628b4ebf4f731a2961a0ebd849f4b56200205056fe67"
- url: "https://pub.dev"
- source: hosted
- version: "4.2.6"
- home_widget:
- dependency: "direct main"
- description:
- name: home_widget
- sha256: "7a32f7d6a3afd542126fb0004acba939a41ee57d874172926212774fbce684d3"
- url: "https://pub.dev"
- source: hosted
- version: "0.9.2"
- hooks:
- dependency: transitive
- description:
- name: hooks
- sha256: a41af4e8fc687cd6d33de9751eb936c8c0204ebe2bcb6c15ecf707504bf47f31
- url: "https://pub.dev"
- source: hosted
- version: "2.0.0"
http:
dependency: "direct main"
description:
@@ -168,22 +96,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.20.2"
- jni:
- dependency: transitive
- description:
- name: jni
- sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f
- url: "https://pub.dev"
- source: hosted
- version: "1.0.0"
- jni_flutter:
- dependency: transitive
- description:
- name: jni_flutter
- sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6"
- url: "https://pub.dev"
- source: hosted
- version: "1.0.1"
leak_tracker:
dependency: transitive
description:
@@ -216,14 +128,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.1.0"
- logging:
- dependency: transitive
- description:
- name: logging
- sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
- url: "https://pub.dev"
- source: hosted
- version: "1.3.0"
matcher:
dependency: transitive
description:
@@ -244,10 +148,10 @@ packages:
dependency: transitive
description:
name: meta
- sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
+ sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev"
source: hosted
- version: "1.17.0"
+ version: "1.18.0"
mocktail:
dependency: "direct dev"
description:
@@ -256,22 +160,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.5"
- objective_c:
- dependency: transitive
- description:
- name: objective_c
- sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed"
- url: "https://pub.dev"
- source: hosted
- version: "9.4.1"
- package_config:
- dependency: transitive
- description:
- name: package_config
- sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
- url: "https://pub.dev"
- source: hosted
- version: "2.2.0"
path:
dependency: transitive
description:
@@ -280,86 +168,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.1"
- path_provider:
- dependency: transitive
- description:
- name: path_provider
- sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
- url: "https://pub.dev"
- source: hosted
- version: "2.1.5"
- path_provider_android:
- dependency: transitive
- description:
- name: path_provider_android
- sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
- url: "https://pub.dev"
- source: hosted
- version: "2.3.1"
- path_provider_foundation:
- dependency: transitive
- description:
- name: path_provider_foundation
- sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
- url: "https://pub.dev"
- source: hosted
- version: "2.6.0"
- path_provider_linux:
- dependency: transitive
- description:
- name: path_provider_linux
- sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
- url: "https://pub.dev"
- source: hosted
- version: "2.2.1"
- path_provider_platform_interface:
- dependency: transitive
- description:
- name: path_provider_platform_interface
- sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
- url: "https://pub.dev"
- source: hosted
- version: "2.1.2"
- path_provider_windows:
- dependency: transitive
- description:
- name: path_provider_windows
- sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
- url: "https://pub.dev"
- source: hosted
- version: "2.3.0"
- platform:
- dependency: transitive
- description:
- name: platform
- sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
- url: "https://pub.dev"
- source: hosted
- version: "3.1.6"
- plugin_platform_interface:
- dependency: transitive
- description:
- name: plugin_platform_interface
- sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
- url: "https://pub.dev"
- source: hosted
- version: "2.1.8"
- pub_semver:
- dependency: transitive
- description:
- name: pub_semver
- sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
- url: "https://pub.dev"
- source: hosted
- version: "2.2.0"
- record_use:
- dependency: transitive
- description:
- name: record_use
- sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
- url: "https://pub.dev"
- source: hosted
- version: "0.6.0"
sky_engine:
dependency: transitive
description: flutter
@@ -409,10 +217,10 @@ packages:
dependency: transitive
description:
name: test_api
- sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
+ sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev"
source: hosted
- version: "0.7.10"
+ version: "0.7.11"
typed_data:
dependency: transitive
description:
@@ -421,14 +229,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
- uuid:
- dependency: transitive
- description:
- name: uuid
- sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
- url: "https://pub.dev"
- source: hosted
- version: "4.5.3"
vector_math:
dependency: transitive
description:
@@ -453,22 +253,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.1"
- xdg_directories:
- dependency: transitive
- description:
- name: xdg_directories
- sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
- url: "https://pub.dev"
- source: hosted
- version: "1.1.0"
- yaml:
- dependency: transitive
- description:
- name: yaml
- sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
- url: "https://pub.dev"
- source: hosted
- version: "3.1.3"
sdks:
dart: ">=3.10.4 <4.0.0"
- flutter: ">=3.38.4"
+ flutter: ">=3.18.0-18.0.pre.54"
diff --git a/pubspec.yaml b/pubspec.yaml
index 39a2637..3c25294 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -36,18 +36,10 @@ dependencies:
# API & Data
http: ^1.2.0
- # Location. Depend on the Android geolocator implementation directly (not the federated
- # `geolocator`) since this is an Android-only app: this avoids pulling the unused
- # desktop/web impls — notably geolocator_linux, which drags in package_info_plus
- # (a Kotlin Gradle Plugin user). geolocator_android registers itself as
- # GeolocatorPlatform.instance; we use that API via geolocator_platform_interface.
- # Reverse geocoding (coords -> city name) is done natively via android.location.Geocoder
- # through our method channel (NativeSvgService.reverseGeocode) — no plugin needed.
- geolocator_android: ^5.0.0
- geolocator_platform_interface: ^4.1.0
-
- # Home screen widget + shared SharedPreferences storage (HomeWidgetPreferences).
- home_widget: ^0.9.2
+ # Location is fully native — no plugin. GPS fixes go through android.location.LocationManager
+ # (LocationProvider.kt) and reverse geocoding through android.location.Geocoder, both via the
+ # org.bortnik.meteogram/svg method channel (LocationBridge / NativeSvgService.reverseGeocode).
+ # This keeps the app free of any Kotlin-Gradle-Plugin plugin, required by AGP-9 built-in Kotlin.
# Internationalization
intl: ^0.20.2
diff --git a/test/home_screen_test.dart b/test/home_screen_test.dart
index dd00115..4a42d09 100644
--- a/test/home_screen_test.dart
+++ b/test/home_screen_test.dart
@@ -34,43 +34,25 @@ void main() {
mockTimestamp = DateTime.now().millisecondsSinceEpoch.toString();
homeWidgetData = {};
- // Mock HomeWidget method channel
+ // Mock the native method channel: SVG generation, weather fetch, and the
+ // widget-store KV API that WidgetStore drives — all on one channel.
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
- const MethodChannel('home_widget'),
+ const MethodChannel('org.bortnik.meteogram/svg'),
(MethodCall methodCall) async {
switch (methodCall.method) {
case 'saveWidgetData':
final args = methodCall.arguments as Map;
final id = args['id'] as String?;
- final data = args['data'];
if (id != null) {
- homeWidgetData[id] = data;
+ homeWidgetData[id] = args['data'];
}
return true;
case 'getWidgetData':
final args = methodCall.arguments as Map;
- final id = args['id'] as String?;
- final defaultValue = args['defaultValue'];
- return homeWidgetData[id] ?? defaultValue;
+ return homeWidgetData[args['id'] as String?];
case 'updateWidget':
return true;
- case 'setAppGroupId':
- return true;
- case 'registerBackgroundCallback':
- return true;
- default:
- return null;
- }
- },
- );
-
- // Mock native SVG method channel
- TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
- .setMockMethodCallHandler(
- const MethodChannel('org.bortnik.meteogram/svg'),
- (MethodCall methodCall) async {
- switch (methodCall.method) {
case 'fetchWeather':
// Simulate successful fetch - populate mock data with current time
homeWidgetData['last_weather_update'] =
@@ -83,35 +65,19 @@ void main() {
case 'reverseGeocode':
// Native Geocoder lookup (coords -> city name)
return mockCityName;
- default:
- return null;
- }
- },
- );
-
- // Mock geolocator channel (for LocationService)
- TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
- .setMockMethodCallHandler(
- const MethodChannel('flutter.baseflow.com/geolocator'),
- (MethodCall methodCall) async {
- switch (methodCall.method) {
- case 'checkPermission':
- return 3; // LocationPermission.whileInUse
+ // LocationBridge methods (native LocationManager)
case 'isLocationServiceEnabled':
return true;
+ case 'checkLocationPermission':
+ return 'granted';
+ case 'requestLocationPermission':
+ return 'granted';
case 'getCurrentPosition':
- return {
- 'latitude': 52.52,
- 'longitude': 13.405,
- 'timestamp': DateTime.now().millisecondsSinceEpoch,
- 'accuracy': 10.0,
- 'altitude': 0.0,
- 'heading': 0.0,
- 'speed': 0.0,
- 'speedAccuracy': 0.0,
- 'altitudeAccuracy': 0.0,
- 'headingAccuracy': 0.0,
- };
+ return {'latitude': 52.52, 'longitude': 13.405};
+ case 'getLastKnownPosition':
+ return {'latitude': 52.52, 'longitude': 13.405};
+ case 'openLocationSettings':
+ return true;
default:
return null;
}
@@ -121,14 +87,9 @@ void main() {
tearDown(() {
// Clean up mock handlers
- TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
- .setMockMethodCallHandler(const MethodChannel('home_widget'), null);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel('org.bortnik.meteogram/svg'), null);
- TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
- .setMockMethodCallHandler(
- const MethodChannel('flutter.baseflow.com/geolocator'), null);
});
/// Helper to wrap HomeScreen with required providers
@@ -318,7 +279,9 @@ void main() {
group('HomeScreen error state', () {
testWidgets('shows error UI when no cached data and fetch fails',
(tester) async {
- // Override to simulate fetch failure
+ // Override to simulate fetch failure. Location methods return null here,
+ // so the bridge falls back to the default location and the failing
+ // fetchWeather drives the error state.
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel('org.bortnik.meteogram/svg'),
@@ -330,24 +293,6 @@ void main() {
},
);
- // Make geolocator fail
- TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
- .setMockMethodCallHandler(
- const MethodChannel('flutter.baseflow.com/geolocator'),
- (MethodCall methodCall) async {
- if (methodCall.method == 'getCurrentPosition') {
- throw PlatformException(code: 'PERMISSION_DENIED');
- }
- if (methodCall.method == 'checkPermission') {
- return 0; // denied
- }
- if (methodCall.method == 'isLocationServiceEnabled') {
- return true;
- }
- return null;
- },
- );
-
await tester.pumpWidget(createTestApp());
// Wait for the error state to appear
for (int i = 0; i < 20; i++) {
diff --git a/test/location_service_test.dart b/test/location_service_test.dart
index 2995ed1..3f814d8 100644
--- a/test/location_service_test.dart
+++ b/test/location_service_test.dart
@@ -18,10 +18,10 @@ http.Response utf8Response(String body, int statusCode) {
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
- // Mock HomeWidget method channel
+ // Mock the native widget-store channel
final Map homeWidgetData = {};
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
- .setMockMethodCallHandler(const MethodChannel('home_widget'), (call) async {
+ .setMockMethodCallHandler(const MethodChannel('org.bortnik.meteogram/svg'), (call) async {
if (call.method == 'saveWidgetData') {
final args = call.arguments as Map;
final id = args['id'] as String?;
diff --git a/test/theme_service_test.dart b/test/theme_service_test.dart
index ca9a973..322cd46 100644
--- a/test/theme_service_test.dart
+++ b/test/theme_service_test.dart
@@ -6,11 +6,11 @@ import 'package:meteogram_widget/services/theme_service.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
- // Mock the HomeWidget method channel with an in-memory map so we can assert
+ // Mock the native widget-store channel with an in-memory map so we can assert
// the value is mirrored to widget storage for the native provider.
final Map homeWidgetData = {};
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
- .setMockMethodCallHandler(const MethodChannel('home_widget'), (call) async {
+ .setMockMethodCallHandler(const MethodChannel('org.bortnik.meteogram/svg'), (call) async {
if (call.method == 'saveWidgetData') {
final args = call.arguments as Map;
final id = args['id'] as String?;