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?;