Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/codeql-default.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 30 additions & 20 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
15 changes: 0 additions & 15 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -68,21 +68,6 @@
android:resource="@xml/meteogram_widget_weekly_info" />
</receiver>

<!-- HomeWidget background receiver for Dart callback invocation -->
<receiver
android:name="es.antonborri.home_widget.HomeWidgetBackgroundReceiver"
android:exported="true">
<intent-filter>
<action android:name="es.antonborri.home_widget.action.BACKGROUND" />
</intent-filter>
</receiver>

<!-- HomeWidget background service for headless Dart execution -->
<service
android:name="es.antonborri.home_widget.HomeWidgetBackgroundService"
android:permission="android.permission.BIND_JOB_SERVICE"
android:exported="false" />

<!-- Widget event receiver for system broadcasts that trigger widget updates -->
<!-- LOCALE_CHANGED and TIMEZONE_CHANGED are exempt from Android 8.0+ restrictions -->
<receiver
Expand Down
128 changes: 128 additions & 0 deletions android/app/src/main/kotlin/org/bortnik/meteogram/LocationProvider.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package org.bortnik.meteogram

import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log

/**
* Native location access via the framework's [LocationManager] — no plugin, no
* Google Play Services. Replaces the geolocator plugin's GPS surface.
*
* All fixes are foreground-only: the home-screen widget's background refresh
* reuses the cached coordinates and never requests a fresh fix, so there is no
* background-location or headless-permission path to handle here.
*/
object LocationProvider {
private const val TAG = "LocationProvider"

/** Providers tried in order: network (low-power, low-accuracy) before GPS. */
private val PROVIDERS = listOf(
LocationManager.NETWORK_PROVIDER,
LocationManager.GPS_PROVIDER,
LocationManager.PASSIVE_PROVIDER,
)

fun isLocationServiceEnabled(context: Context): Boolean {
val lm = context.getSystemService(Context.LOCATION_SERVICE) as? LocationManager ?: return false
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
lm.isLocationEnabled
} else {
@Suppress("DEPRECATION")
(lm.isProviderEnabled(LocationManager.GPS_PROVIDER) ||
lm.isProviderEnabled(LocationManager.NETWORK_PROVIDER))
}
}

/** "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)
}
}
Loading
Loading