Skip to content
Closed
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
16 changes: 14 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,8 @@ Android widgets use RemoteViews which only support:
**NOT supported:** View, Space, custom views, most Material widgets

### Data Flow
1. **In-app**: `home_screen.dart` gets location → calls `NativeSvgService.fetchWeather()` → Kotlin fetches from Open-Meteo → caches to SharedPreferences → Dart reads cache → Kotlin generates SVG → rendered via `NativeSvgChartView`
2. **Widget**: Native code reads cached weather from SharedPreferences → `SvgChartGenerator.kt` generates SVG → AndroidSVG renders to bitmap → ImageView
1. **In-app**: `home_screen.dart` gets location → saves coordinates to SharedPreferences via `home_widget` (`saved_latitude`/`saved_longitude` as Long-encoded doubles) → calls `NativeSvgService.fetchWeather()` → Kotlin fetches from Open-Meteo → caches weather JSON to SharedPreferences → Dart reads cache → Kotlin generates SVG → rendered via `NativeSvgChartView`
2. **Widget**: Native code reads `saved_latitude`/`saved_longitude` from SharedPreferences (Long-encoded doubles from home_widget) → `WeatherFetcher.kt` fetches from Open-Meteo → caches weather JSON → `SvgChartGenerator.kt` generates SVG → AndroidSVG renders to bitmap → ImageView

### Background Refresh (fully native)
- **AlarmManager**: `WidgetAlarmScheduler.kt` schedules 15-min inexact alarm (catches up on wake)
Expand Down Expand Up @@ -238,6 +238,18 @@ adb logcat | grep -i "Error inflating"
| `lib/theme/app_theme.dart` | All colors and gradients |
| `scripts/generate_version.sh` | Generates version.dart from git tag/commit |

## Upgrade Safety

**Renaming SharedPreferences keys requires migration.** Users have data stored under old key names that must not be lost or misinterpreted on upgrade. When renaming a key:

1. Add migration code in `LocationService.migrateIfNeeded()` (called from `main.dart` at startup)
2. Read old key → write new key → remove old key
3. Migrate both `SharedPreferences` and `HomeWidget` storage (they're separate stores)
4. Keep the legacy key constant in code for reference
5. Migration must be idempotent (safe to run multiple times)

See `location_source` → `saved_location_source` migration as the reference pattern.

## Gotchas

1. **RemoteViews errors** - Check logcat for "Class not allowed to be inflated"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package org.bortnik.meteogram

import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import org.json.JSONArray
import org.json.JSONObject
Expand All @@ -22,22 +23,45 @@ object WeatherFetcher {
private const val TIMEOUT_MS = 10_000
private const val PAST_HOURS = 6

/**
* Read user's saved location from SharedPreferences.
* Dart writes these via home_widget, which stores doubles as Long
* via Double.doubleToRawLongBits().
* @return (latitude, longitude) pair, or null if no location is saved.
*/
fun getLocation(prefs: SharedPreferences): Pair<Double, Double>? {
if (prefs.contains("saved_latitude") && prefs.contains("saved_longitude")) {
val latitude = Double.fromBits(prefs.getLong("saved_latitude", 0L))
val longitude = Double.fromBits(prefs.getLong("saved_longitude", 0L))
return Pair(latitude, longitude)
}

// Upgrade fallback: pre-v1.0.9 wrote cached_* as Float from previous fetches.
// Bridges GPS users who haven't opened the app since upgrade.
if (prefs.contains("cached_latitude") && prefs.contains("cached_longitude")) {
val latitude = prefs.getFloat("cached_latitude", 0f).toDouble()
val longitude = prefs.getFloat("cached_longitude", 0f).toDouble()
Log.d(TAG, "Using cached_* fallback for upgrade compatibility")
return Pair(latitude, longitude)
}

return null
}

/**
* Fetch weather data synchronously and save to SharedPreferences.
* Uses cached location from SharedPreferences.
* Uses saved location from SharedPreferences.
* Call from background thread only.
*/
fun fetchAndUpdateSync(context: Context) {
val prefs = context.getSharedPreferences(WidgetUtils.PREFS_NAME, Context.MODE_PRIVATE)

// Get cached location
val latitude = prefs.getFloat("cached_latitude", 0f).toDouble()
val longitude = prefs.getFloat("cached_longitude", 0f).toDouble()

if (latitude == 0.0 && longitude == 0.0) {
Log.w(TAG, "No cached location available")
val location = getLocation(prefs)
if (location == null) {
Log.w(TAG, "No saved location available")
return
}
val (latitude, longitude) = location

if (fetchWeatherSync(context, latitude, longitude)) {
// Trigger widget update
Expand Down Expand Up @@ -67,9 +91,16 @@ object WeatherFetcher {
return false
}

// Check if user changed location while we were fetching
val prefs = context.getSharedPreferences(WidgetUtils.PREFS_NAME, Context.MODE_PRIVATE)
val currentLocation = getLocation(prefs)
if (currentLocation != null && (currentLocation.first != latitude || currentLocation.second != longitude)) {
Log.d(TAG, "Location changed during fetch, discarding stale result")
return false
}

// Save to SharedPreferences
// Note: Values read by Dart via home_widget must be stored as strings
val prefs = context.getSharedPreferences(WidgetUtils.PREFS_NAME, Context.MODE_PRIVATE)
val now = System.currentTimeMillis()

// Extract current temperature using same logic as Dart's getNowIndex()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -249,12 +249,8 @@ class WeatherFetcherTest {
// ==================== fetchAndUpdateSync Tests ====================

@Test
fun `fetchAndUpdateSync returns early without cached location`() {
// No location cached - should return without crashing
fun `fetchAndUpdateSync returns early without saved location`() {
prefs.edit().clear().commit()

// This will log "No cached location available" and return
// We can't easily verify the return since it's void, but it shouldn't crash
try {
WeatherFetcher.fetchAndUpdateSync(context)
} catch (e: Exception) {
Expand All @@ -263,20 +259,126 @@ class WeatherFetcherTest {
}

@Test
fun `fetchAndUpdateSync returns early with zero coordinates`() {
// 0,0 location is treated as "no location"
fun `fetchAndUpdateSync uses cached Float coordinates as upgrade fallback`() {
// Pre-v1.0.9 wrote cached_* as Float; getLocation() falls back to these
// when saved_* (Long) keys don't exist yet
prefs.edit()
.putFloat("cached_latitude", 0f)
.putFloat("cached_longitude", 0f)
.putFloat("cached_latitude", 52.52f)
.putFloat("cached_longitude", 13.405f)
.commit()

// Should not crash — getLocation() reads cached_* as fallback,
// then fetchWeatherSync attempts HTTP (fails silently in test)
try {
WeatherFetcher.fetchAndUpdateSync(context)
} catch (e: Exception) {
fail("Should not throw: ${e.message}")
}
}

// ==================== getLocation Tests ====================

@Test
fun `getLocation returns null when no location keys exist`() {
prefs.edit().clear().commit()
assertNull(WeatherFetcher.getLocation(prefs))
}

@Test
fun `getLocation reads saved coordinates from home_widget format`() {
// home_widget stores doubles as Long via Double.doubleToRawLongBits()
val lat = 52.52
val lon = 13.405
prefs.edit()
.putLong("saved_latitude", lat.toRawBits())
.putLong("saved_longitude", lon.toRawBits())
.commit()

val location = WeatherFetcher.getLocation(prefs)
assertNotNull(location)
assertEquals(lat, location!!.first, 0.0001)
assertEquals(lon, location.second, 0.0001)
}

@Test
fun `getLocation handles zero coordinates as valid location`() {
val lat = 0.0
val lon = 0.0
prefs.edit()
.putLong("saved_latitude", lat.toRawBits())
.putLong("saved_longitude", lon.toRawBits())
.commit()

val location = WeatherFetcher.getLocation(prefs)
assertNotNull(location)
assertEquals(lat, location!!.first, 0.0001)
assertEquals(lon, location.second, 0.0001)
}

@Test
fun `getLocation handles negative coordinates`() {
val lat = -33.87
val lon = -151.21
prefs.edit()
.putLong("saved_latitude", lat.toRawBits())
.putLong("saved_longitude", lon.toRawBits())
.commit()

val location = WeatherFetcher.getLocation(prefs)
assertNotNull(location)
assertEquals(lat, location!!.first, 0.0001)
assertEquals(lon, location.second, 0.0001)
}

@Test
fun `getLocation returns null when only latitude is saved`() {
prefs.edit()
.putLong("saved_latitude", 52.52.toRawBits())
.commit()

assertNull(WeatherFetcher.getLocation(prefs))
}

@Test
fun `getLocation returns null when only longitude is saved`() {
prefs.edit()
.putLong("saved_longitude", 13.405.toRawBits())
.commit()

assertNull(WeatherFetcher.getLocation(prefs))
}

@Test
fun `getLocation falls back to cached Float coordinates for upgrade`() {
// Pre-v1.0.9: fetchWeatherSync wrote cached_* as Float
prefs.edit()
.putFloat("cached_latitude", 52.52f)
.putFloat("cached_longitude", 13.405f)
.commit()

val location = WeatherFetcher.getLocation(prefs)
assertNotNull(location)
assertEquals(52.52, location!!.first, 0.01)
assertEquals(13.405, location.second, 0.01)
}

@Test
fun `getLocation prefers saved Long over cached Float`() {
val lat = 48.85
val lon = 2.35
prefs.edit()
.putLong("saved_latitude", lat.toRawBits())
.putLong("saved_longitude", lon.toRawBits())
.putFloat("cached_latitude", 52.52f)
.putFloat("cached_longitude", 13.405f)
.commit()

val location = WeatherFetcher.getLocation(prefs)
assertNotNull(location)
assertEquals(lat, location!!.first, 0.0001)
assertEquals(lon, location.second, 0.0001)
}

// ==================== Helper Methods ====================

private fun createMockApiResponse(): JSONObject {
Expand Down
39 changes: 39 additions & 0 deletions docs/ai/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,45 @@ class _HomeScreenState extends State<HomeScreen> {
}
```

## SharedPreferences Keys

All keys live in the `HomeWidgetPreferences` file (shared between Dart via `home_widget` and native Kotlin).

### User Intent (`saved_*`) — written by Dart via `home_widget`

| Key | Type | Written by | Read by | Purpose |
|-----|------|-----------|---------|---------|
| `saved_latitude` | Long (double bits) | `location_service.dart` | `WeatherFetcher.kt` | User's chosen latitude |
| `saved_longitude` | Long (double bits) | `location_service.dart` | `WeatherFetcher.kt` | User's chosen longitude |
| `saved_city` | String | `location_service.dart` | `location_service.dart` | User's chosen city name |
| `saved_location_source` | String | `location_service.dart` | `location_service.dart` | User's chosen mode (`gps` or `manual`) |

**Note:** `home_widget` stores Dart `double` values as `Long` via `Double.doubleToRawLongBits()`. Native Kotlin must read with `getLong()` + `Double.fromBits()`, not `getFloat()`.

### Cache (`cached_*`) — written by Kotlin after successful fetch

| Key | Type | Written by | Read by | Purpose |
|-----|------|-----------|---------|---------|
| `cached_weather` | String (JSON) | `WeatherFetcher.kt` | `WeatherDataParser.kt`, Dart | Full weather response |
| `cached_latitude` | Float | `WeatherFetcher.kt` | — (reserved for future use) | Last successfully fetched latitude |
| `cached_longitude` | Float | `WeatherFetcher.kt` | — (reserved for future use) | Last successfully fetched longitude |
| `cached_city_name` | String | `home_screen.dart` | `home_screen.dart` | Last displayed city name |
| `cached_location_source` | String | `home_screen.dart` | `home_screen.dart` | Location source from last successful display (for UI restore) |
| `current_temperature_celsius` | String | `WeatherFetcher.kt` | `native_svg_service.dart` | Current temp for quick display |

### Widget State — written/read by Kotlin

| Key | Type | Written by | Read by | Purpose |
|-----|------|-----------|---------|---------|
| `last_weather_update` | String (millis) | `WeatherFetcher.kt` | `WidgetUtils.kt`, `native_svg_service.dart` | Timestamp of last successful fetch |
| `last_render_time` | Long (millis) | `WidgetUtils.kt` | `WidgetUtils.kt` | Timestamp of last widget render |
| `widget_width_px` | Int | `MeteogramWidgetProvider.kt` | `WidgetUtils.kt` | Widget width in pixels |
| `widget_height_px` | Int | `MeteogramWidgetProvider.kt` | `WidgetUtils.kt` | Widget height in pixels |
| `widget_ids` | String (CSV) | `MeteogramWidgetProvider.kt` | `WidgetUtils.kt` | Active widget IDs |
| `widget_resized` | Boolean | `MeteogramWidgetProvider.kt` | `home_screen.dart` | Flag to trigger in-app re-render |
| `svg_path_light` | String | `WidgetUtils.kt` | `MeteogramWidgetProvider.kt` | Path to light theme SVG |
| `svg_path_dark` | String | `WidgetUtils.kt` | `MeteogramWidgetProvider.kt` | Path to dark theme SVG |

## Key Components

### SvgChartGenerator (`android/.../SvgChartGenerator.kt`)
Expand Down
33 changes: 13 additions & 20 deletions docs/ai/widget.md
Original file line number Diff line number Diff line change
Expand Up @@ -393,24 +393,16 @@ Future<void> homeWidgetBackgroundCallback(Uri? uri) async {
}
}

// NOTE: Re-rendering is now handled natively in Kotlin.
// WidgetUtils.rerenderAllWidgetsNative() reads cached weather from
// SharedPreferences and generates SVGs via SvgChartGenerator.kt.
// The Dart-based _reRenderCharts flow below is historical reference only.

// Re-render from cached data (no network call)
// URI params: width, height, locale (all optional, fallback to cached/Platform values)
Future<void> _reRenderCharts([Uri? uri]) async {
// Extract params from URI (more reliable than Platform.localeName in cold-start)
final uriWidth = int.tryParse(uri?.queryParameters['width'] ?? '');
final uriHeight = int.tryParse(uri?.queryParameters['height'] ?? '');
final uriLocale = uri?.queryParameters['locale'];

final cachedJson = await HomeWidget.getWidgetData<String>('cached_weather');
if (cachedJson == null) return;

final weather = WeatherData.fromJson(jsonDecode(cachedJson));
final latitude = await HomeWidget.getWidgetData<double>('cached_latitude') ?? 0.0;
final longitude = await HomeWidget.getWidgetData<double>('cached_longitude') ?? 0.0;

await _generateSvgCharts(weather, latitude, longitude,
uriWidth: uriWidth, uriHeight: uriHeight, uriLocale: uriLocale);
await HomeWidget.updateWidget(androidName: 'MeteogramWidgetProvider');
// Native Kotlin handles SVG generation and widget update
}
```

Expand Down Expand Up @@ -530,13 +522,14 @@ void main() async {

## Data Flow

1. **App loads weather** from Open-Meteo API
2. **SVG generated** in Dart via `SvgChartGenerator` (both light AND dark themes)
3. **SVG files saved** to app documents folder (`meteogram_light.svg`, `meteogram_dark.svg`)
4. **Widget data saved** via HomeWidget.saveWidgetData → SharedPreferences (SVG paths)
1. **App saves location** via `HomeWidget.saveWidgetData` (`saved_latitude`/`saved_longitude` as Long-encoded doubles, `saved_city`)
2. **App loads weather** from Open-Meteo API (via native `WeatherFetcher.kt`)
3. **SVG generated** natively in Kotlin via `SvgChartGenerator.kt` (both light AND dark themes)
4. **Weather JSON cached** to SharedPreferences (`cached_weather`)
5. **Native provider** reads SVG, renders via AndroidSVG → Bitmap → ImageView
6. **In-app display** uses same pipeline via PlatformView (AndroidView)
7. **Theme switching** handled automatically by Android resource system
6. **Background refresh** reads `saved_latitude`/`saved_longitude` from SharedPreferences (user's chosen location)
7. **In-app display** uses same native SVG pipeline via PlatformView (AndroidView)
8. **Theme switching** handled automatically by Android resource system

**Key benefit:** SVG generation works in background isolates (no Flutter UI required), enabling true background updates.

Expand Down
2 changes: 2 additions & 0 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import 'l10n/app_localizations.dart';
import 'package:flutter_localizations/flutter_localizations.dart';

import 'screens/home_screen.dart';
import 'services/location_service.dart';
import 'services/widget_service.dart';
import 'services/material_you_service.dart';
import 'theme/app_theme.dart';
Expand All @@ -19,6 +20,7 @@ void main() async {
systemNavigationBarDividerColor: Colors.transparent,
));

await LocationService.migrateIfNeeded();
await WidgetService.initialize();

// Load Material You colors from native Android code
Expand Down
9 changes: 9 additions & 0 deletions lib/screens/home_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,15 @@ class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
try {
final location = await _locationService.getLocation();

// Persist coordinates for native background refresh (AlarmManager, WorkManager, etc.)
// GPS coordinates change each fetch; background services need the latest.
await _locationService.saveLocation(
location.latitude,
location.longitude,
city: location.city,
source: location.source,
);

// Fetch weather via native Kotlin HTTP client
final success = await NativeSvgService.fetchWeather(
latitude: location.latitude,
Expand Down
Loading