From bb96cb0da37d11e69ba2d4b4c53e598b384bbcc0 Mon Sep 17 00:00:00 2001 From: Tymofiy Bortnyk Date: Sat, 17 Jan 2026 13:15:16 +0200 Subject: [PATCH] Move weather fetching and SVG generation to native Kotlin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add WeatherFetcher.kt for native HTTP requests to Open-Meteo API - Add WeatherDataParser.kt for parsing cached weather JSON - Add SvgChartGenerator.kt as the single source of SVG generation - Add NativeSvgService for Dart↔Kotlin method channel communication - Remove Dart implementations: weather_service.dart, background_service.dart, svg_chart_generator.dart, native_svg_renderer.dart, locale_utils.dart - Remove obsolete constants (ChartConstants, AlarmConstants) - Update home_widget to 0.9.0 and remove version workaround docs - Force Java 17 for all subprojects to eliminate deprecation warnings - Clean up dead code in widget_service.dart and units_service.dart Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 48 +- .../org/bortnik/meteogram/MainActivity.kt | 120 ++++ .../meteogram/MaterialYouColorWorker.kt | 4 +- .../bortnik/meteogram/MeteogramApplication.kt | 4 +- .../meteogram/MeteogramWidgetProvider.kt | 258 ++++++-- .../bortnik/meteogram/SvgChartGenerator.kt | 582 ++++++++++++++++++ .../bortnik/meteogram/WeatherDataParser.kt | 164 +++++ .../org/bortnik/meteogram/WeatherFetcher.kt | 210 +++++++ .../bortnik/meteogram/WeatherUpdateWorker.kt | 4 +- .../bortnik/meteogram/WidgetEventReceiver.kt | 3 +- .../org/bortnik/meteogram/WidgetUtils.kt | 98 ++- android/build.gradle.kts | 20 + docs/HOME_WIDGET_VERSION_ISSUE.md | 107 ---- lib/constants.dart | 36 -- lib/main.dart | 2 - lib/screens/home_screen.dart | 285 ++++----- lib/services/background_service.dart | 472 -------------- lib/services/native_svg_renderer.dart | 28 - lib/services/native_svg_service.dart | 135 ++++ lib/services/svg_chart_generator.dart | 490 --------------- lib/services/units_service.dart | 52 -- lib/services/weather_service.dart | 210 ------- lib/services/widget_service.dart | 278 +-------- lib/utils/locale_utils.dart | 41 -- macos/Flutter/GeneratedPluginRegistrant.swift | 2 - pubspec.lock | 88 ++- pubspec.yaml | 8 +- test/background_service_test.dart | 221 ------- test/locale_utils_test.dart | 103 ---- test/native_svg_renderer_test.dart | 143 ----- test/native_svg_service_test.dart | 179 ++++++ test/svg_chart_generator_test.dart | 495 --------------- test/units_service_test.dart | 149 ----- test/weather_service_test.dart | 398 ------------ test/widget_service_test.dart | 108 ---- 35 files changed, 1922 insertions(+), 3623 deletions(-) create mode 100644 android/app/src/main/kotlin/org/bortnik/meteogram/SvgChartGenerator.kt create mode 100644 android/app/src/main/kotlin/org/bortnik/meteogram/WeatherDataParser.kt create mode 100644 android/app/src/main/kotlin/org/bortnik/meteogram/WeatherFetcher.kt delete mode 100644 docs/HOME_WIDGET_VERSION_ISSUE.md delete mode 100644 lib/services/background_service.dart delete mode 100644 lib/services/native_svg_renderer.dart create mode 100644 lib/services/native_svg_service.dart delete mode 100644 lib/services/svg_chart_generator.dart delete mode 100644 lib/services/weather_service.dart delete mode 100644 lib/utils/locale_utils.dart delete mode 100644 test/background_service_test.dart delete mode 100644 test/locale_utils_test.dart delete mode 100644 test/native_svg_renderer_test.dart create mode 100644 test/native_svg_service_test.dart delete mode 100644 test/svg_chart_generator_test.dart delete mode 100644 test/weather_service_test.dart delete mode 100644 test/widget_service_test.dart diff --git a/CLAUDE.md b/CLAUDE.md index a50bb44..148684b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,11 +46,9 @@ lib/ ├── models/ │ └── weather_data.dart # WeatherData, HourlyData classes ├── services/ -│ ├── weather_service.dart # Open-Meteo API client │ ├── location_service.dart # Geolocator wrapper with fallback │ ├── widget_service.dart # home_widget integration -│ ├── background_service.dart # home_widget callbacks for background updates -│ └── svg_chart_generator.dart # Pure Dart SVG generation +│ └── native_svg_service.dart # Method channel to native (weather fetch, SVG gen, cache) ├── theme/ │ └── app_theme.dart # MeteogramColors, WeatherGradients ├── widgets/ @@ -61,11 +59,14 @@ lib/ android/app/src/main/ ├── kotlin/.../ │ ├── MainActivity.kt -│ ├── MeteogramApplication.kt # Registers event receivers +│ ├── MeteogramApplication.kt # Registers event receivers, theme observer │ ├── MeteogramWidgetProvider.kt # Extends HomeWidgetProvider │ ├── WidgetEventReceiver.kt # Handles system broadcasts │ ├── WidgetUtils.kt # Widget helper functions -│ ├── WeatherUpdateWorker.kt # WorkManager periodic refresh +│ ├── WeatherUpdateWorker.kt # WorkManager periodic refresh (~30 min) +│ ├── WeatherFetcher.kt # Native HTTP client for Open-Meteo API +│ ├── WeatherDataParser.kt # Parse cached weather JSON +│ ├── SvgChartGenerator.kt # Native SVG generation (single source) │ ├── MaterialYouColorExtractor.kt # Native Material You color extraction │ ├── SvgChartPlatformView.kt # Native SVG rendering for in-app │ └── SvgChartViewFactory.kt # PlatformView factory @@ -119,15 +120,14 @@ Android widgets use RemoteViews which only support: **NOT supported:** View, Space, custom views, most Material widgets ### Data Flow -1. `home_screen.dart` loads weather → generates SVG via `SvgChartGenerator` -2. In-app: SVG rendered via `NativeSvgChartView` (Android PlatformView) -3. Widget: SVG saved to file, `HomeWidget.updateWidget()` triggers native update -4. `MeteogramWidgetProvider.kt` reads SVG file, renders via AndroidSVG → ImageView +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 -### Background Refresh +### Background Refresh (fully native) - **WorkManager periodic**: `WeatherUpdateWorker.kt` runs ~30 min (battery-efficient, OS batches work) -- **Event-driven**: `WidgetEventReceiver.kt` handles unlock, network, locale changes -- **home_widget callbacks**: `background_service.dart` handles `weatherUpdate` and `chartReRender` URIs +- **Weather fetching**: `WeatherFetcher.kt` calls Open-Meteo API directly (no Dart involved) +- **Event-driven**: `WidgetEventReceiver.kt` handles unlock, network, locale/timezone changes +- **Material You**: `ContentObserver` + `MaterialYouColorWorker.kt` detect theme changes ## Build Commands @@ -153,10 +153,10 @@ Do not commit code with analyzer warnings or test failures. ## Common Tasks ### Modify chart appearance -Edit `lib/services/svg_chart_generator.dart`: -- `_writeTemperatureLine()` - line style, gradient fill -- `_writePrecipitationBars()` - bar colors, width -- `_writeSunshineBars()` - daylight intensity display +Edit `android/.../SvgChartGenerator.kt`: +- `writeTemperatureLine()` - line style, gradient fill +- `writePrecipitationBars()` - bar colors, width +- `writeSunshineBars()` - daylight intensity display - `SvgChartColors` - color definitions for light/dark themes ### Modify widget layout @@ -180,14 +180,16 @@ adb logcat | grep -i "Error inflating" | File | Purpose | |------|---------| -| `lib/services/svg_chart_generator.dart` | Pure Dart SVG generation (core chart) | -| `lib/widgets/native_svg_chart_view.dart` | In-app SVG display via PlatformView | -| `lib/services/background_service.dart` | home_widget callbacks for background updates | -| `lib/theme/app_theme.dart` | All colors and gradients | -| `android/.../MeteogramWidgetProvider.kt` | Native widget code | -| `android/.../WeatherUpdateWorker.kt` | WorkManager periodic refresh | +| `android/.../SvgChartGenerator.kt` | Native SVG generation (single source of truth) | +| `android/.../WeatherFetcher.kt` | Native HTTP client for background weather fetching | +| `android/.../WeatherDataParser.kt` | Parse cached weather JSON from SharedPreferences | +| `android/.../MeteogramWidgetProvider.kt` | Widget update handling | +| `android/.../WeatherUpdateWorker.kt` | WorkManager periodic refresh (~30 min) | | `android/.../MaterialYouColorExtractor.kt` | Native Material You color extraction | -| `android/.../WidgetEventReceiver.kt` | System event handler | +| `android/.../WidgetEventReceiver.kt` | System event handler (unlock, network, etc.) | +| `lib/services/native_svg_service.dart` | Method channel to native (weather, SVG, cache) | +| `lib/services/location_service.dart` | GPS/manual location with city search | +| `lib/theme/app_theme.dart` | All colors and gradients | ## Gotchas 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 93099db..df49f84 100644 --- a/android/app/src/main/kotlin/org/bortnik/meteogram/MainActivity.kt +++ b/android/app/src/main/kotlin/org/bortnik/meteogram/MainActivity.kt @@ -2,15 +2,20 @@ package org.bortnik.meteogram import android.graphics.Bitmap import android.graphics.Canvas +import android.os.Build +import android.text.format.DateFormat +import android.util.Log import com.caverock.androidsvg.SVG import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream +import java.util.Locale class MainActivity : FlutterActivity() { private val CHANNEL = "org.bortnik.meteogram/svg" + private val TAG = "MainActivity" override fun configureFlutterEngine(flutterEngine: FlutterEngine) { // Extract Material You colors BEFORE Flutter engine starts @@ -27,6 +32,29 @@ class MainActivity : FlutterActivity() { MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result -> when (call.method) { + "generateSvg" -> { + val width = call.argument("width") ?: 0 + val height = call.argument("height") ?: 0 + val isLight = call.argument("isLight") ?: true + val usesFahrenheit = call.argument("usesFahrenheit") ?: false + + if (width <= 0 || height <= 0) { + result.error("INVALID_ARGS", "Invalid dimensions: ${width}x${height}", null) + return@setMethodCallHandler + } + + try { + val svgString = generateSvgFromCache(width, height, isLight, usesFahrenheit) + if (svgString != null) { + result.success(svgString) + } else { + result.error("NO_DATA", "No weather data available", null) + } + } catch (e: Exception) { + Log.e(TAG, "Error generating SVG", e) + result.error("GENERATE_ERROR", e.message, null) + } + } "renderSvg" -> { val svgString = call.argument("svg") val width = call.argument("width") ?: 0 @@ -44,11 +72,103 @@ class MainActivity : FlutterActivity() { result.error("RENDER_ERROR", e.message, null) } } + "fetchWeather" -> { + val latitude = call.argument("latitude") + val longitude = call.argument("longitude") + + if (latitude == null || longitude == null) { + result.error("INVALID_ARGS", "Missing latitude or longitude", null) + return@setMethodCallHandler + } + + // Run on background thread + Thread { + try { + val success = WeatherFetcher.fetchWeatherSync(this, latitude, longitude) + runOnUiThread { + if (success) { + result.success(true) + } else { + result.error("FETCH_FAILED", "Failed to fetch weather data", null) + } + } + } catch (e: Exception) { + Log.e(TAG, "Error fetching weather", e) + runOnUiThread { + result.error("FETCH_ERROR", e.message, null) + } + } + }.start() + } else -> result.notImplemented() } } } + /** + * Generate SVG string from cached weather data. + */ + private fun generateSvgFromCache( + width: Int, + height: Int, + isLight: Boolean, + usesFahrenheit: Boolean + ): String? { + val weatherData = WeatherDataParser.parseFromPrefs(this) + if (weatherData == null) { + Log.d(TAG, "No cached weather data for SVG generation") + return null + } + + val displayData = weatherData.getDisplayRange() + val nowIndex = weatherData.getNowIndex() + + // Get colors with Material You support + val colors = getChartColors(isLight) + + val generator = SvgChartGenerator() + return generator.generate( + data = displayData, + nowIndex = nowIndex, + latitude = weatherData.latitude, + longitude = weatherData.longitude, + colors = colors, + width = width.toDouble(), + height = height.toDouble(), + locale = Locale.getDefault(), + usesFahrenheit = usesFahrenheit, + use24HourFormat = DateFormat.is24HourFormat(this) + ) + } + + /** + * Get chart colors with Material You support. + */ + private fun getChartColors(isLight: Boolean): SvgChartColors { + val baseColors = if (isLight) SvgChartColors.light else SvgChartColors.dark + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + return baseColors + } + + val prefs = getSharedPreferences(WidgetUtils.PREFS_NAME, MODE_PRIVATE) + + val tempColorKey = if (isLight) "material_you_light_on_primary_container" else "material_you_dark_primary" + val timeColorKey = if (isLight) "material_you_light_tertiary" else "material_you_dark_tertiary" + + val tempColor = prefs.getInt(tempColorKey, 0) + val timeColor = prefs.getInt(timeColorKey, 0) + + if (tempColor == 0 || timeColor == 0) { + return baseColors + } + + return baseColors.withDynamicColors( + temperatureLine = SvgColor.fromArgb(tempColor), + timeLabel = SvgColor.fromArgb(timeColor) + ) + } + private fun renderSvgToPng(svgString: String, width: Int, height: Int): ByteArray { val svg = SVG.getFromInputStream(ByteArrayInputStream(svgString.toByteArray())) svg.documentWidth = width.toFloat() diff --git a/android/app/src/main/kotlin/org/bortnik/meteogram/MaterialYouColorWorker.kt b/android/app/src/main/kotlin/org/bortnik/meteogram/MaterialYouColorWorker.kt index 64c9ba7..1041bf1 100644 --- a/android/app/src/main/kotlin/org/bortnik/meteogram/MaterialYouColorWorker.kt +++ b/android/app/src/main/kotlin/org/bortnik/meteogram/MaterialYouColorWorker.kt @@ -75,8 +75,8 @@ class MaterialYouColorWorker( try { // Check if colors actually changed if (MaterialYouColorExtractor.updateColorsIfChanged(applicationContext)) { - Log.d(TAG, "Material You colors changed - triggering re-render for all widgets") - WidgetUtils.rerenderAllWidgets(applicationContext) + Log.d(TAG, "Material You colors changed - triggering native re-render for all widgets") + WidgetUtils.rerenderAllWidgetsNative(applicationContext) } else { Log.d(TAG, "Colors unchanged or already up to date") } diff --git a/android/app/src/main/kotlin/org/bortnik/meteogram/MeteogramApplication.kt b/android/app/src/main/kotlin/org/bortnik/meteogram/MeteogramApplication.kt index 50c4770..23cbe4a 100644 --- a/android/app/src/main/kotlin/org/bortnik/meteogram/MeteogramApplication.kt +++ b/android/app/src/main/kotlin/org/bortnik/meteogram/MeteogramApplication.kt @@ -47,8 +47,8 @@ class MeteogramApplication : Application() { override fun onChange(selfChange: Boolean) { Log.d(TAG, "Theme customization changed (ContentObserver)") if (MaterialYouColorExtractor.updateColorsIfChanged(applicationContext)) { - Log.d(TAG, "Material You colors changed - triggering re-render for all widgets") - WidgetUtils.rerenderAllWidgets(applicationContext) + Log.d(TAG, "Material You colors changed - triggering native re-render for all widgets") + WidgetUtils.rerenderAllWidgetsNative(applicationContext) } } } 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 3d6ee67..e74f9c1 100644 --- a/android/app/src/main/kotlin/org/bortnik/meteogram/MeteogramWidgetProvider.kt +++ b/android/app/src/main/kotlin/org/bortnik/meteogram/MeteogramWidgetProvider.kt @@ -10,13 +10,16 @@ import android.os.Build import android.graphics.BitmapFactory import android.graphics.Canvas import android.os.Bundle +import android.text.format.DateFormat import android.util.Log 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 +import java.util.Locale class MeteogramWidgetProvider : HomeWidgetProvider() { companion object { @@ -45,6 +48,109 @@ class MeteogramWidgetProvider : HomeWidgetProvider() { return if (width > 0 && height > 0) Pair(width, height) else null } + /** + * Get chart colors for a theme, applying Material You colors if available. + */ + private fun getChartColors(context: Context, isLight: Boolean): SvgChartColors { + val baseColors = if (isLight) SvgChartColors.light else SvgChartColors.dark + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + return baseColors + } + + val prefs = context.getSharedPreferences(WidgetUtils.PREFS_NAME, Context.MODE_PRIVATE) + + // Get Material You colors from SharedPreferences (saved by MaterialYouColorExtractor) + val tempColorKey = if (isLight) "material_you_light_on_primary_container" else "material_you_dark_primary" + val timeColorKey = if (isLight) "material_you_light_tertiary" else "material_you_dark_tertiary" + + val tempColor = prefs.getInt(tempColorKey, 0) + val timeColor = prefs.getInt(timeColorKey, 0) + + if (tempColor == 0 || timeColor == 0) { + Log.d(TAG, "Material You colors not available, using defaults") + return baseColors + } + + Log.d(TAG, "Applying Material You colors: temp=${Integer.toHexString(tempColor)}, time=${Integer.toHexString(timeColor)}") + return baseColors.withDynamicColors( + temperatureLine = SvgColor.fromArgb(tempColor), + timeLabel = SvgColor.fromArgb(timeColor) + ) + } + + /** + * Check if device uses Fahrenheit based on locale. + * US, Liberia, and Myanmar use Fahrenheit. + */ + private fun usesFahrenheit(): Boolean { + val country = Locale.getDefault().country + return country in listOf("US", "LR", "MM") + } + + /** + * Generate SVG chart natively and render to bitmap. + * @return Bitmap or null if generation fails + */ + private fun generateChartBitmap( + context: Context, + weatherData: WeatherData, + colors: SvgChartColors, + width: Int, + height: Int + ): Bitmap? { + if (width <= 0 || height <= 0) { + Log.e(TAG, "Invalid dimensions for native SVG generation: ${width}x${height}") + return null + } + + return try { + val displayData = weatherData.getDisplayRange() + val nowIndex = weatherData.getNowIndex() + + val generator = SvgChartGenerator() + val svgString = generator.generate( + data = displayData, + nowIndex = nowIndex, + latitude = weatherData.latitude, + longitude = weatherData.longitude, + colors = colors, + width = width.toDouble(), + height = height.toDouble(), + locale = Locale.getDefault(), + usesFahrenheit = usesFahrenheit(), + use24HourFormat = DateFormat.is24HourFormat(context) + ) + + // Render SVG string to bitmap + renderSvgStringToBitmap(svgString, width, height) + } catch (e: Exception) { + Log.e(TAG, "Error generating native chart", e) + null + } + } + + /** + * Render an SVG string to a Bitmap. + */ + private fun renderSvgStringToBitmap(svgString: String, width: Int, height: Int): Bitmap? { + return try { + ByteArrayInputStream(svgString.toByteArray()).use { inputStream -> + val svg = SVG.getFromInputStream(inputStream) + svg.documentWidth = width.toFloat() + svg.documentHeight = height.toFloat() + + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + svg.renderToCanvas(canvas) + bitmap + } + } catch (e: Exception) { + Log.e(TAG, "Error rendering SVG string to bitmap", e) + null + } + } + /** * Update the list of active widget IDs. */ @@ -174,16 +280,57 @@ class MeteogramWidgetProvider : HomeWidgetProvider() { // Save per-widget dimensions saveWidgetDimensions(prefs, appWidgetId, widthPx, heightPx, density) - // Also save as "current" dimensions for backward compatibility and app preview + // Set flag for app to detect resize on resume prefs.edit() - .putInt(WidgetUtils.KEY_WIDGET_WIDTH_PX, widthPx) - .putInt(WidgetUtils.KEY_WIDGET_HEIGHT_PX, heightPx) - .putFloat("widget_density", density) .putBoolean("widget_resized", true) .commit() - // Trigger chart re-render for this widget - WidgetUtils.rerenderChartForWidget(context, appWidgetId, widthPx, heightPx) + // Try to generate chart natively for immediate update + val weatherData = WeatherDataParser.parseFromPrefs(context) + if (weatherData != null && widthPx > 0 && heightPx > 0) { + Log.d(TAG, "Generating native chart for resize") + + val views = RemoteViews(context.packageName, R.layout.meteogram_widget) + + // Generate light theme chart + val lightColors = getChartColors(context, isLight = true) + val lightBitmap = generateChartBitmap(context, weatherData, lightColors, widthPx, heightPx) + if (lightBitmap != null) { + views.setImageViewBitmap(R.id.widget_chart_light, lightBitmap) + Log.d(TAG, "Native light chart generated") + } + + // Generate dark theme chart + val darkColors = getChartColors(context, isLight = false) + val darkBitmap = generateChartBitmap(context, weatherData, darkColors, widthPx, heightPx) + if (darkBitmap != null) { + views.setImageViewBitmap(R.id.widget_chart_dark, darkBitmap) + Log.d(TAG, "Native dark chart generated") + } + + if (lightBitmap != null || darkBitmap != null) { + views.setViewVisibility(R.id.widget_placeholder, View.GONE) + views.setViewVisibility(R.id.widget_refresh_indicator, View.GONE) + + // Set up tap to open app + val intent = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + val pendingIntent = PendingIntent.getActivity( + context, 0, intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + views.setOnClickPendingIntent(R.id.widget_root, pendingIntent) + + appWidgetManager.updateAppWidget(appWidgetId, views) + Log.d(TAG, "Widget $appWidgetId updated with native charts") + return + } + } + + // Fallback: trigger weather fetch which will cache data and update widget + Log.d(TAG, "Native generation failed or no weather data, triggering weather fetch") + WidgetUtils.fetchWeather(context) } override fun onDeleted(context: Context, appWidgetIds: IntArray) { @@ -204,17 +351,13 @@ class MeteogramWidgetProvider : HomeWidgetProvider() { updateWidgetIdsList(context, appWidgetIds) // Check for Material You color changes (Android 12+) - // This detects wallpaper/theme color changes and triggers SVG re-generation if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - if (MaterialYouColorExtractor.updateColorsIfChanged(context)) { - Log.d(TAG, "Material You colors changed - triggering re-render for all widgets") - WidgetUtils.rerenderAllWidgets(context) - // Skip rendering with old SVGs - wait for background service to generate new ones - // The re-render will trigger another onUpdate with correct colors - return - } + MaterialYouColorExtractor.updateColorsIfChanged(context) } + // Try to load weather data for native generation + val weatherData = WeatherDataParser.parseFromPrefs(context) + for (appWidgetId in appWidgetIds) { val views = RemoteViews(context.packageName, R.layout.meteogram_widget) @@ -233,12 +376,6 @@ class MeteogramWidgetProvider : HomeWidgetProvider() { // Save per-widget dimensions (only if valid) if (widthPx > 0 && heightPx > 0) { saveWidgetDimensions(widgetData, appWidgetId, widthPx, heightPx, density) - // Also save as "current" for backward compatibility - widgetData.edit() - .putInt(WidgetUtils.KEY_WIDGET_WIDTH_PX, widthPx) - .putInt(WidgetUtils.KEY_WIDGET_HEIGHT_PX, heightPx) - .putFloat("widget_density", density) - .commit() } else { // Try to use saved per-widget dimensions first val savedDims = getWidgetDimensions(widgetData, appWidgetId) @@ -247,55 +384,75 @@ class MeteogramWidgetProvider : HomeWidgetProvider() { heightPx = savedDims.second Log.d(TAG, "Widget $appWidgetId using saved dimensions: ${widthPx}x${heightPx}px") } else { - // Fall back to global default - val (defaultWidth, defaultHeight) = WidgetUtils.getWidgetDimensions(context) - widthPx = defaultWidth - heightPx = defaultHeight + // Fall back to defaults + widthPx = WidgetUtils.DEFAULT_WIDTH_PX + heightPx = WidgetUtils.DEFAULT_HEIGHT_PX Log.d(TAG, "Widget $appWidgetId using default dimensions: ${widthPx}x${heightPx}px") } } // Set up tap to open app - val intent = android.content.Intent(context, MainActivity::class.java).apply { - flags = android.content.Intent.FLAG_ACTIVITY_NEW_TASK or android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP - component = android.content.ComponentName(context, MainActivity::class.java) + val intent = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP } val pendingIntent = PendingIntent.getActivity( - context, - 0, - intent, + context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) views.setOnClickPendingIntent(R.id.widget_root, pendingIntent) - // Get chart sources - prefer widget-specific SVG, fall back to generic - val svgLightPath = widgetData.getString("svg_path_light_$appWidgetId", null) - ?: widgetData.getString("svg_path_light", null) - val svgDarkPath = widgetData.getString("svg_path_dark_$appWidgetId", null) - ?: widgetData.getString("svg_path_dark", null) - Log.d(TAG, "Widget $appWidgetId SVG paths - light: $svgLightPath, dark: $svgDarkPath") - var hasChart = false - // Load light theme chart - val lightBitmap = loadChartBitmap(svgLightPath, null, widthPx, heightPx) - if (lightBitmap != null) { - views.setImageViewBitmap(R.id.widget_chart_light, lightBitmap) - hasChart = true - Log.d(TAG, "Widget $appWidgetId light chart loaded") + // Try native generation first (works without file dependencies) + if (weatherData != null && widthPx > 0 && heightPx > 0) { + // Generate light theme chart + val lightColors = getChartColors(context, isLight = true) + val lightBitmap = generateChartBitmap(context, weatherData, lightColors, widthPx, heightPx) + if (lightBitmap != null) { + views.setImageViewBitmap(R.id.widget_chart_light, lightBitmap) + hasChart = true + Log.d(TAG, "Widget $appWidgetId native light chart generated") + } + + // Generate dark theme chart + val darkColors = getChartColors(context, isLight = false) + val darkBitmap = generateChartBitmap(context, weatherData, darkColors, widthPx, heightPx) + if (darkBitmap != null) { + views.setImageViewBitmap(R.id.widget_chart_dark, darkBitmap) + hasChart = true + Log.d(TAG, "Widget $appWidgetId native dark chart generated") + } } - // Load dark theme chart - val darkBitmap = loadChartBitmap(svgDarkPath, null, widthPx, heightPx) - if (darkBitmap != null) { - views.setImageViewBitmap(R.id.widget_chart_dark, darkBitmap) - hasChart = true - Log.d(TAG, "Widget $appWidgetId dark chart loaded") + // Fallback: try loading from SVG files (backward compat with Dart-generated charts) + if (!hasChart) { + val svgLightPath = widgetData.getString("svg_path_light_$appWidgetId", null) + ?: widgetData.getString("svg_path_light", null) + val svgDarkPath = widgetData.getString("svg_path_dark_$appWidgetId", null) + ?: widgetData.getString("svg_path_dark", null) + Log.d(TAG, "Widget $appWidgetId falling back to SVG files - light: $svgLightPath, dark: $svgDarkPath") + + // Load light theme chart from file + val lightBitmap = loadChartBitmap(svgLightPath, null, widthPx, heightPx) + if (lightBitmap != null) { + views.setImageViewBitmap(R.id.widget_chart_light, lightBitmap) + hasChart = true + Log.d(TAG, "Widget $appWidgetId light chart loaded from file") + } + + // Load dark theme chart from file + val darkBitmap = loadChartBitmap(svgDarkPath, null, widthPx, heightPx) + if (darkBitmap != null) { + views.setImageViewBitmap(R.id.widget_chart_dark, darkBitmap) + hasChart = true + Log.d(TAG, "Widget $appWidgetId dark chart loaded from file") + } } // Show placeholder if no charts available if (!hasChart) { views.setViewVisibility(R.id.widget_placeholder, View.VISIBLE) + Log.d(TAG, "Widget $appWidgetId showing placeholder - no weather data") } else { views.setViewVisibility(R.id.widget_placeholder, View.GONE) } @@ -305,5 +462,8 @@ class MeteogramWidgetProvider : HomeWidgetProvider() { appWidgetManager.updateAppWidget(appWidgetId, views) Log.d(TAG, "Updated widget $appWidgetId") } + + // Update last render time + WidgetUtils.updateLastRenderTime(context) } } diff --git a/android/app/src/main/kotlin/org/bortnik/meteogram/SvgChartGenerator.kt b/android/app/src/main/kotlin/org/bortnik/meteogram/SvgChartGenerator.kt new file mode 100644 index 0000000..72eb7d5 --- /dev/null +++ b/android/app/src/main/kotlin/org/bortnik/meteogram/SvgChartGenerator.kt @@ -0,0 +1,582 @@ +package org.bortnik.meteogram + +import java.util.Calendar +import java.util.Date +import java.util.Locale +import java.util.TimeZone +import kotlin.math.asin +import kotlin.math.cos +import kotlin.math.exp +import kotlin.math.pow +import kotlin.math.sin +import kotlin.math.sqrt + +/** + * SVG color representation for chart generation. + */ +data class SvgColor( + val r: Int, + val g: Int, + val b: Int, + val a: Int = 255 +) { + /** + * Convert to hex color string (#RRGGBB). + */ + fun toHex(): String = "#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}" + + /** + * Get opacity as 0.0-1.0 value. + */ + val opacity: Double get() = a / 255.0 + + companion object { + /** + * Create from ARGB int value (e.g., Android Color.toArgb()). + */ + fun fromArgb(argb: Int): SvgColor = SvgColor( + r = (argb shr 16) and 0xFF, + g = (argb shr 8) and 0xFF, + b = argb and 0xFF, + a = (argb shr 24) and 0xFF + ) + } +} + +/** + * Chart colors for SVG generation. + */ +data class SvgChartColors( + val temperatureLine: SvgColor, + val temperatureGradientStart: SvgColor, + val temperatureGradientEnd: SvgColor, + val precipitationBar: SvgColor, + val daylightBar: SvgColor, + val nowIndicator: SvgColor, + val timeLabel: SvgColor, + val cardBackground: SvgColor, + val primaryText: SvgColor +) { + /** + * Create colors with custom temperature line and time label colors. + * Used to apply Material You dynamic colors. + */ + fun withDynamicColors(temperatureLine: SvgColor, timeLabel: SvgColor): SvgChartColors = copy( + temperatureLine = temperatureLine, + temperatureGradientStart = SvgColor( + temperatureLine.r, + temperatureLine.g, + temperatureLine.b, + temperatureGradientStart.a + ), + temperatureGradientEnd = SvgColor( + temperatureLine.r, + temperatureLine.g, + temperatureLine.b, + 0x00 + ), + timeLabel = timeLabel + ) + + companion object { + val light = SvgChartColors( + temperatureLine = SvgColor(0xFF, 0x6B, 0x6B), + temperatureGradientStart = SvgColor(0xFF, 0x6B, 0x6B, 0x40), + temperatureGradientEnd = SvgColor(0xFF, 0x6B, 0x6B, 0x00), + precipitationBar = SvgColor(0x4E, 0xCD, 0xC4), + daylightBar = SvgColor(0xFF, 0x8F, 0x00), // Dark amber (visible on white) + nowIndicator = SvgColor(0x4A, 0x55, 0x68), + timeLabel = SvgColor(0x4A, 0x55, 0x68), + cardBackground = SvgColor(0xFF, 0xFF, 0xFF), + primaryText = SvgColor(0x2D, 0x34, 0x36) + ) + + val dark = SvgChartColors( + temperatureLine = SvgColor(0xFF, 0x76, 0x75), + temperatureGradientStart = SvgColor(0xFF, 0x76, 0x75, 0x60), + temperatureGradientEnd = SvgColor(0xFF, 0x76, 0x75, 0x00), + precipitationBar = SvgColor(0x00, 0xCE, 0xC9), + daylightBar = SvgColor(0xFF, 0xFF, 0x00), // Pure yellow + nowIndicator = SvgColor(0xE0, 0xE0, 0xE0), + timeLabel = SvgColor(0xE0, 0xE0, 0xE0), + cardBackground = SvgColor(0x2D, 0x2D, 0x2D), // Neutral gray + primaryText = SvgColor(0xFF, 0xFF, 0xFF) + ) + } +} + +/** + * Hourly weather data for chart generation. + */ +data class HourlyData( + val time: Long, // UTC timestamp in milliseconds + val temperature: Double, // Celsius + val precipitation: Double, // mm + val cloudCover: Int // 0-100 +) + +/** + * Chart visual constants for SVG generation. + */ +object ChartConstants { + /** Time label font size as ratio of chart width (4% of width). */ + const val TIME_FONT_SIZE_RATIO = 0.04 + + /** Temperature label font size as ratio of chart width (4.5% of width). */ + const val TEMP_FONT_SIZE_RATIO = 0.045 + + /** Bar width as ratio of slot width (70% of available space). */ + const val BAR_WIDTH_RATIO = 0.7 + + /** Chart height as percentage of total height (95%). */ + const val CHART_HEIGHT_RATIO = 0.95 + + /** Temperature range vertical padding (10% of range). */ + const val TEMP_RANGE_PADDING_RATIO = 0.10 + + /** Opacity for daylight bars. */ + const val DAYLIGHT_BAR_OPACITY = 0.8 + + /** Opacity for precipitation bars. */ + const val PRECIPITATION_BAR_OPACITY = 0.85 +} + +/** + * Generates SVG meteogram charts natively in Kotlin. + * This allows widget updates without starting a Flutter engine. + */ +class SvgChartGenerator { + + private var locale: Locale = Locale.getDefault() + private var usesFahrenheit: Boolean = false + private var use24HourFormat: Boolean = true + + /** + * Generate SVG chart string. + * + * @param data Hourly weather data points + * @param nowIndex Index of current hour in data array + * @param latitude Geographic latitude for daylight calculation + * @param longitude Geographic longitude for daylight calculation + * @param colors Theme colors to use + * @param width Chart width in pixels + * @param height Chart height in pixels + * @param locale Locale for time formatting + * @param usesFahrenheit Whether to display temperature in Fahrenheit + * @param use24HourFormat Whether to use 24-hour time format (false = 12-hour with AM/PM) + * @param usePastFade Whether to fade past data + * @return SVG string + */ + fun generate( + data: List, + nowIndex: Int, + latitude: Double, + longitude: Double, + colors: SvgChartColors, + width: Double, + height: Double, + locale: Locale = Locale.getDefault(), + usesFahrenheit: Boolean = false, + use24HourFormat: Boolean = true, + usePastFade: Boolean = true + ): String { + this.locale = locale + this.usesFahrenheit = usesFahrenheit + this.use24HourFormat = use24HourFormat + + if (data.isEmpty()) { + return """""" + } + + val svg = StringBuilder() + + // Reserve space for time labels based on font size + val timeFontSize = width * ChartConstants.TIME_FONT_SIZE_RATIO + val chartHeight = (height - timeFontSize * 1.5) * ChartConstants.CHART_HEIGHT_RATIO + val nowFraction = (nowIndex + 1).toDouble() / data.size + + svg.append("""""") + + // Gradient definitions + svg.append("") + writeGradientDefs(svg, colors, nowFraction, usePastFade) + svg.append("") + + // No background - widget uses system background via ?android:attr/colorBackground + + // Chart group with optional past-time fade mask + if (usePastFade) { + svg.append("""""") + } else { + svg.append("") + } + + // Daylight bars (with gradient) + writeDaylightBars(svg, data, latitude, longitude, colors, width, chartHeight) + + // Precipitation bars (with gradient) + writePrecipitationBars(svg, data, colors, width, chartHeight) + + // Temperature line (with gradient fill) + writeTemperatureLine(svg, data, colors, width, chartHeight) + + // Now indicator + val nowX = (nowIndex.toDouble() / (data.size - 1)) * width + svg.append("""""") + + // Grid lines at 12h intervals + var i = nowIndex + 12 + while (i < data.size - 8) { + val x = (i.toDouble() / (data.size - 1)) * width + svg.append("""""") + i += 12 + } + + svg.append("") + + // Temperature labels (outside mask for full opacity) + writeTempLabels(svg, data, colors, width, chartHeight, nowFraction) + + // Time labels + writeTimeLabels(svg, data, nowIndex, colors, width, height, chartHeight) + + svg.append("") + return svg.toString() + } + + /** + * Write gradient definitions to SVG defs section. + */ + private fun writeGradientDefs( + svg: StringBuilder, + colors: SvgChartColors, + nowFraction: Double, + usePastFade: Boolean + ) { + // Temperature area gradient (vertical: line color fading to transparent) + svg.append("""""") + svg.append("""""") + svg.append("""""") + svg.append("") + + // Daylight bar gradient (vertical: fades at top, semi-solid at bottom) + svg.append("""""") + svg.append("""""") + svg.append("""""") + svg.append("") + + // Precipitation bar gradient (vertical: fades at top, semi-solid at bottom) + svg.append("""""") + svg.append("""""") + svg.append("""""") + svg.append("") + + // Past-time fade mask (horizontal gradient: faded on left, full opacity at now line) + if (usePastFade) { + val fadeStop1 = (nowFraction * 0.75 * 100).toInt() + val fadeStop2 = (nowFraction * 100).toInt() + svg.append("""""") + svg.append("""""") + svg.append("""""") + svg.append("""""") + svg.append("""""") + svg.append("") + svg.append("""""") + } + } + + /** + * Write daylight bars to SVG. + */ + private fun writeDaylightBars( + svg: StringBuilder, + data: List, + latitude: Double, + longitude: Double, + colors: SvgChartColors, + width: Double, + chartHeight: Double + ) { + val slotWidth = width / data.size + val barWidth = slotWidth * ChartConstants.BAR_WIDTH_RATIO + + svg.append("""""") + for (i in data.indices) { + val daylight = calculateDaylight(data[i], latitude, longitude) + if (daylight <= 0) continue + + val barHeight = daylight * chartHeight + val x = i * slotWidth + (slotWidth - barWidth) / 2 + + svg.append("""""") + } + svg.append("") + } + + /** + * Write precipitation bars to SVG. + */ + private fun writePrecipitationBars( + svg: StringBuilder, + data: List, + colors: SvgChartColors, + width: Double, + chartHeight: Double + ) { + val maxPrecip = data.maxOfOrNull { it.precipitation } ?: 0.0 + if (maxPrecip == 0.0) return + + val slotWidth = width / data.size + val barWidth = slotWidth * ChartConstants.BAR_WIDTH_RATIO + + svg.append("""""") + for (i in data.indices) { + val precip = data[i].precipitation + if (precip <= 0) continue + + val normalized = (precip / 10.0).coerceIn(0.0, 1.0) + val barHeight = sqrt(normalized) * chartHeight + val x = i * slotWidth + (slotWidth - barWidth) / 2 + + svg.append("""""") + } + svg.append("") + } + + /** + * Write temperature line with gradient fill to SVG. + */ + private fun writeTemperatureLine( + svg: StringBuilder, + data: List, + colors: SvgChartColors, + width: Double, + chartHeight: Double + ) { + val temps = data.map { it.temperature } + val minTemp = temps.minOrNull() ?: 0.0 + val maxTemp = temps.maxOrNull() ?: 0.0 + val tempRange = (maxTemp - minTemp).coerceAtLeast(1.0) + val yPadding = tempRange * ChartConstants.TEMP_RANGE_PADDING_RATIO + + val points = mutableListOf>() + for (i in data.indices) { + val x = (i.toDouble() / (data.size - 1)) * width + val normalizedTemp = (data[i].temperature - minTemp + yPadding) / (tempRange + 2 * yPadding) + val y = chartHeight * (1 - normalizedTemp) + points.add(Pair(x, y)) + } + + // Build smooth cubic bezier path + val path = StringBuilder("M ${points[0].first.toInt()} ${points[0].second.toInt()}") + for (i in 1 until points.size) { + val p0 = points[i - 1] + val p1 = points[i] + val dx = p1.first - p0.first + val cp1x = p0.first + dx * 0.35 + val cp2x = p1.first - dx * 0.35 + path.append(" C ${cp1x.toInt()} ${p0.second.toInt()} ${cp2x.toInt()} ${p1.second.toInt()} ${p1.first.toInt()} ${p1.second.toInt()}") + } + + // Area fill with gradient + val areaPath = "$path L ${width.toInt()} ${chartHeight.toInt()} L 0 ${chartHeight.toInt()} Z" + svg.append("""""") + + // Temperature line with dark outline for visibility on daylight bars + svg.append("""""") + svg.append("""""") + } + + /** + * Format temperature for display, converting to Fahrenheit if needed. + */ + private fun formatTemp(celsius: Double): String { + return if (usesFahrenheit) { + (celsius * 9 / 5 + 32).toInt().toString() + } else { + celsius.toInt().toString() + } + } + + /** + * Write temperature labels to SVG. + */ + private fun writeTempLabels( + svg: StringBuilder, + data: List, + colors: SvgChartColors, + width: Double, + chartHeight: Double, + nowFraction: Double + ) { + val temps = data.map { it.temperature } + val minTemp = temps.minOrNull() ?: 0.0 + val maxTemp = temps.maxOrNull() ?: 0.0 + val midTemp = (minTemp + maxTemp) / 2 + val tempRange = (maxTemp - minTemp).coerceAtLeast(1.0) + val yPadding = tempRange * 0.10 + + // Use same Y calculation as temperature line for alignment + fun tempToY(temp: Double): Double { + val normalizedTemp = (temp - minTemp + yPadding) / (tempRange + 2 * yPadding) + return chartHeight * (1 - normalizedTemp) + } + + val centerX = (nowFraction / 2.5) * width + + // Font size relative to width + val fontSize = (width * ChartConstants.TEMP_FONT_SIZE_RATIO).toInt() + val style = """fill="${colors.temperatureLine.toHex()}" font-size="$fontSize" font-weight="bold" font-family="sans-serif" text-anchor="middle"""" + + // Align labels with actual temperature positions on the line + // Add offset to account for text height + val yOffset = fontSize * 0.4 + svg.append("""${formatTemp(maxTemp)}""") + svg.append("""${formatTemp(midTemp)}""") + svg.append("""${formatTemp(minTemp)}""") + } + + /** + * Write time labels to SVG. + */ + private fun writeTimeLabels( + svg: StringBuilder, + data: List, + nowIndex: Int, + colors: SvgChartColors, + width: Double, + height: Double, + chartHeight: Double + ) { + // Font size relative to width + val fontSize = (width * ChartConstants.TIME_FONT_SIZE_RATIO).toInt() + // Position labels 60% down in the area below the chart + val labelY = chartHeight + (height - chartHeight) * 0.6 + val style = """fill="${colors.timeLabel.toHex()}" font-size="$fontSize" font-weight="600" font-family="sans-serif" text-anchor="middle" dominant-baseline="middle"""" + + var i = nowIndex + while (i < data.size - 8) { + val offset = i - nowIndex + if (offset >= 0 && offset % 12 == 0) { + val date = Date(data[i].time) + val timeStr = formatHourOnly(date) + val x = (i.toDouble() / (data.size - 1)) * width + + svg.append("""$timeStr""") + } + i++ + } + } + + /** + * Format time to show only hour (locale-aware: "3 PM" or "15"). + */ + private fun formatHourOnly(date: Date): String { + val calendar = Calendar.getInstance().apply { + time = date + timeZone = TimeZone.getDefault() + } + val hour = calendar.get(Calendar.HOUR_OF_DAY) + + return if (!use24HourFormat) { + val hour12 = if (hour == 0) 12 else if (hour > 12) hour - 12 else hour + val amPm = if (hour < 12) "AM" else "PM" + "$hour12 $amPm" + } else { + hour.toString() + } + } + + // ==================== Scientific Calculations ==================== + + /** + * Calculate solar elevation angle using simplified solar position algorithm. + * + * This determines how high the sun is above the horizon at a given time and location. + * Uses a simplified approximation suitable for daylight visualization. + * + * @param latitude Geographic latitude in degrees (-90 to +90) + * @param longitude Geographic longitude in degrees (-180 to +180, positive = East) + * @param timeMs UTC time in milliseconds + * @return Solar elevation angle in degrees (negative = below horizon) + */ + private fun solarElevation(latitude: Double, longitude: Double, timeMs: Long): Double { + val calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC")).apply { + timeInMillis = timeMs + } + + val dayOfYear = calendar.get(Calendar.DAY_OF_YEAR) + val utcHour = calendar.get(Calendar.HOUR_OF_DAY) + calendar.get(Calendar.MINUTE) / 60.0 + + // Solar declination: angle between sun's rays and equatorial plane + val declination = 23.45 * sin(2 * Math.PI / 365 * (284 + dayOfYear)) + + // Convert UTC to local solar time using longitude + val solarHour = utcHour + longitude / 15.0 + + // Hour angle: angular distance from solar noon + val hourAngle = 15.0 * (solarHour - 12) + + // Convert to radians + val latRad = Math.toRadians(latitude) + val decRad = Math.toRadians(declination) + val haRad = Math.toRadians(hourAngle) + + // Spherical trigonometry formula for solar elevation + val sinElevation = sin(latRad) * sin(decRad) + cos(latRad) * cos(decRad) * cos(haRad) + return Math.toDegrees(asin(sinElevation.coerceIn(-1.0, 1.0))) + } + + /** + * Calculate clear-sky illuminance at ground level in lux. + * + * Estimates how bright daylight would be with perfectly clear skies. + * + * @param elevation Solar elevation angle in degrees + * @return Illuminance in lux (0 to ~133,000) + */ + private fun clearSkyIlluminance(elevation: Double): Double { + if (elevation < -6) return 0.0 // Below astronomical twilight + + val elevRad = Math.toRadians(elevation) + val u = sin(elevRad) + + // Atmospheric mass approximation + val x = 753.66156 + val s = asin((x * cos(elevRad) / (x + 1)).coerceIn(-1.0, 1.0)) + val m = x * (cos(s) - u) + cos(s) + + // Atmospheric extinction and scattering + val factor = exp(-0.2 * m) * u + 0.0289 * exp(-0.042 * m) * (1 + (elevation + 90) * u / 57.29577951) + + return 133775 * factor.coerceAtLeast(0.0) + } + + /** + * Calculate effective daylight intensity (0.0 to 1.0) for display as bar height. + * + * Combines solar position, cloud cover, and precipitation. + * + * @param data Hourly weather data + * @param latitude Geographic latitude + * @param longitude Geographic longitude + * @return Normalized daylight intensity (0.0 to 1.0) + */ + private fun calculateDaylight(data: HourlyData, latitude: Double, longitude: Double): Double { + val elevation = solarElevation(latitude, longitude, data.time) + val clearSkyLux = clearSkyIlluminance(elevation) + if (clearSkyLux <= 0) return 0.0 + + // Normalize to 0-1 range + val potential = (clearSkyLux / 130000.0).coerceIn(0.0, 1.0) + + // Attenuate by cloud cover (exponential) + val cloudDivisor = 10.0.pow(data.cloudCover / 100.0) + + // Attenuate by precipitation + val precipDivisor = 1 + 0.5 * data.precipitation.pow(0.6) + + // Apply sqrt for perceptual brightness + return sqrt(potential / cloudDivisor / precipDivisor) + } +} diff --git a/android/app/src/main/kotlin/org/bortnik/meteogram/WeatherDataParser.kt b/android/app/src/main/kotlin/org/bortnik/meteogram/WeatherDataParser.kt new file mode 100644 index 0000000..9c3ab7b --- /dev/null +++ b/android/app/src/main/kotlin/org/bortnik/meteogram/WeatherDataParser.kt @@ -0,0 +1,164 @@ +package org.bortnik.meteogram + +import android.content.Context +import android.util.Log +import org.json.JSONObject +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone + +/** + * Constants matching Dart weather_data.dart. + */ +object WeatherConstants { + /** Hours of past data to request from API. */ + const val PAST_HOURS = 6 + + /** Hours of future data to display on chart. */ + const val FORECAST_HOURS = 46 + + /** Total display range in hours (past + forecast). */ + const val DISPLAY_RANGE_HOURS = PAST_HOURS + FORECAST_HOURS +} + +/** + * Parsed weather data from cache. + */ +data class WeatherData( + val timezone: String, + val latitude: Double, + val longitude: Double, + val hourly: List, + val fetchedAt: Long // milliseconds +) { + /** + * Get data for display range (limited to DISPLAY_RANGE_HOURS). + */ + fun getDisplayRange(): List { + val endIndex = WeatherConstants.DISPLAY_RANGE_HOURS.coerceAtMost(hourly.size) + return hourly.subList(0, endIndex) + } + + /** + * Get index of "now" in the display range. + * Returns PAST_HOURS (clamped to valid range). + */ + fun getNowIndex(): Int { + return WeatherConstants.PAST_HOURS.coerceIn(0, hourly.size - 1) + } +} + +/** + * Parses cached weather data from SharedPreferences. + */ +object WeatherDataParser { + private const val TAG = "WeatherDataParser" + + /** + * Parse cached weather JSON from SharedPreferences. + * @return WeatherData or null if not available/invalid + */ + fun parseFromPrefs(context: Context): WeatherData? { + val prefs = context.getSharedPreferences(WidgetUtils.PREFS_NAME, Context.MODE_PRIVATE) + val jsonStr = prefs.getString("cached_weather", null) + if (jsonStr == null) { + Log.d(TAG, "No cached weather data") + return null + } + + return try { + parseJson(jsonStr) + } catch (e: Exception) { + Log.e(TAG, "Error parsing weather data", e) + null + } + } + + /** + * Parse weather JSON string. + */ + fun parseJson(jsonStr: String): WeatherData { + val json = JSONObject(jsonStr) + val timezone = json.getString("timezone") + val latitude = json.getDouble("latitude") + val longitude = json.getDouble("longitude") + val fetchedAtStr = json.getString("fetchedAt") + val fetchedAt = parseIso8601(fetchedAtStr) + + val hourlyJson = json.getJSONObject("hourly") + val times = hourlyJson.getJSONArray("time") + val temperatures = hourlyJson.getJSONArray("temperature_2m") + val precipitation = hourlyJson.getJSONArray("precipitation") + val cloudCover = hourlyJson.getJSONArray("cloud_cover") + + // Find minimum length to prevent index errors + val minLength = minOf( + times.length(), + temperatures.length(), + precipitation.length(), + cloudCover.length() + ) + + val hourlyData = mutableListOf() + for (i in 0 until minLength) { + if (times.isNull(i) || temperatures.isNull(i)) continue + + val timeStr = times.getString(i) + val timeMs = parseIso8601(timeStr) + + hourlyData.add( + HourlyData( + time = timeMs, + temperature = temperatures.getDouble(i), + precipitation = precipitation.optDouble(i, 0.0), + cloudCover = cloudCover.optInt(i, 0) + ) + ) + } + + Log.d(TAG, "Parsed ${hourlyData.size} hourly data points") + return WeatherData( + timezone = timezone, + latitude = latitude, + longitude = longitude, + hourly = hourlyData, + fetchedAt = fetchedAt + ) + } + + /** + * Parse ISO 8601 timestamp to milliseconds. + * Handles formats with or without milliseconds/microseconds and Z suffix. + */ + private fun parseIso8601(str: String): Long { + // Remove trailing Z if present + var normalized = str.removeSuffix("Z") + + // Truncate microseconds to milliseconds if present (Dart may output 6 digits) + // Pattern: "2023-01-01T12:00:00.123456" -> "2023-01-01T12:00:00.123" + val dotIndex = normalized.lastIndexOf('.') + if (dotIndex > 0 && normalized.length - dotIndex > 4) { + normalized = normalized.substring(0, dotIndex + 4) + } + + // Try parsing with milliseconds first, then without + val formats = listOf( + "yyyy-MM-dd'T'HH:mm:ss.SSS", + "yyyy-MM-dd'T'HH:mm:ss", + "yyyy-MM-dd'T'HH:mm" + ) + + for (pattern in formats) { + try { + val format = SimpleDateFormat(pattern, Locale.US) + format.timeZone = TimeZone.getTimeZone("UTC") + return format.parse(normalized)?.time ?: continue + } catch (e: Exception) { + // Try next format + } + } + + Log.w(TAG, "Failed to parse timestamp: $str") + return 0L + } +} diff --git a/android/app/src/main/kotlin/org/bortnik/meteogram/WeatherFetcher.kt b/android/app/src/main/kotlin/org/bortnik/meteogram/WeatherFetcher.kt new file mode 100644 index 0000000..38586c4 --- /dev/null +++ b/android/app/src/main/kotlin/org/bortnik/meteogram/WeatherFetcher.kt @@ -0,0 +1,210 @@ +package org.bortnik.meteogram + +import android.content.Context +import android.util.Log +import org.json.JSONArray +import org.json.JSONObject +import java.io.BufferedReader +import java.io.InputStreamReader +import java.net.HttpURLConnection +import java.net.URL +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone +import java.util.concurrent.Executors + +/** + * Native weather fetcher for Open-Meteo API. + * Replaces the Dart-based HomeWidgetBackgroundIntent approach for more reliable background updates. + */ +object WeatherFetcher { + private const val TAG = "WeatherFetcher" + private const val BASE_URL = "https://api.open-meteo.com/v1/forecast" + private const val TIMEOUT_MS = 10_000 + private const val PAST_HOURS = 6 + + private val executor = Executors.newSingleThreadExecutor() + + /** + * Fetch weather data asynchronously and save to SharedPreferences. + * Triggers widget update on success. + */ + fun fetchAndUpdate(context: Context) { + executor.execute { + try { + fetchAndUpdateSync(context) + } catch (e: Exception) { + Log.e(TAG, "Background fetch failed", e) + } + } + } + + /** + * Fetch weather data synchronously and save to SharedPreferences. + * Uses cached 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") + return + } + + if (fetchWeatherSync(context, latitude, longitude)) { + // Trigger widget update + WidgetUtils.rerenderAllWidgetsNative(context) + } + } + + /** + * Fetch weather data synchronously for given coordinates. + * Saves to SharedPreferences. Does NOT trigger widget update (caller's responsibility). + * Call from background thread only. + * @return true on success, false on failure + */ + fun fetchWeatherSync(context: Context, latitude: Double, longitude: Double): Boolean { + Log.d(TAG, "Fetching weather for $latitude, $longitude") + + val jsonResponse = fetchFromApi(latitude, longitude) + if (jsonResponse == null) { + Log.e(TAG, "Failed to fetch weather from API") + return false + } + + // Transform API response to cached format (matching Dart's toJson()) + val cachedJson = transformApiResponse(jsonResponse) + if (cachedJson == null) { + Log.e(TAG, "Failed to transform API response") + return false + } + + // Save to SharedPreferences + val prefs = context.getSharedPreferences(WidgetUtils.PREFS_NAME, Context.MODE_PRIVATE) + val now = System.currentTimeMillis() + prefs.edit() + .putString("cached_weather", cachedJson.toString()) + .putFloat("cached_latitude", latitude.toFloat()) + .putFloat("cached_longitude", longitude.toFloat()) + .putLong(WidgetUtils.KEY_LAST_WEATHER_UPDATE, now) + .apply() + + Log.d(TAG, "Weather data cached successfully") + return true + } + + /** + * Fetch weather data from Open-Meteo API. + * @return JSON response or null on failure + */ + private fun fetchFromApi(latitude: Double, longitude: Double): JSONObject? { + val url = URL(buildUrl(latitude, longitude)) + var connection: HttpURLConnection? = null + + return try { + connection = url.openConnection() as HttpURLConnection + connection.requestMethod = "GET" + connection.connectTimeout = TIMEOUT_MS + connection.readTimeout = TIMEOUT_MS + + val responseCode = connection.responseCode + if (responseCode != HttpURLConnection.HTTP_OK) { + Log.e(TAG, "API returned $responseCode") + return null + } + + val reader = BufferedReader(InputStreamReader(connection.inputStream)) + val response = StringBuilder() + var line: String? + while (reader.readLine().also { line = it } != null) { + response.append(line) + } + reader.close() + + JSONObject(response.toString()) + } catch (e: Exception) { + Log.e(TAG, "Network error", e) + null + } finally { + connection?.disconnect() + } + } + + private fun buildUrl(latitude: Double, longitude: Double): String { + return "$BASE_URL?" + + "latitude=$latitude" + + "&longitude=$longitude" + + "&hourly=temperature_2m,precipitation,cloud_cover" + + "&timezone=UTC" + + "&past_hours=$PAST_HOURS" + + "&forecast_days=2" + } + + /** + * Transform Open-Meteo API response to cached format (matching Dart's WeatherData.toJson()). + * Main difference: API times don't have Z suffix, we need to add it and add fetchedAt. + */ + private fun transformApiResponse(apiJson: JSONObject): JSONObject? { + return try { + val hourly = apiJson.getJSONObject("hourly") + val times = hourly.getJSONArray("time") + + // Convert times to ISO 8601 with Z suffix + val normalizedTimes = JSONArray() + for (i in 0 until times.length()) { + val time = times.getString(i) + // API returns "2023-01-01T00:00", we need "2023-01-01T00:00:00.000Z" + val normalized = normalizeTimestamp(time) + normalizedTimes.put(normalized) + } + + // Create cached format + val cachedHourly = JSONObject().apply { + put("time", normalizedTimes) + put("temperature_2m", hourly.getJSONArray("temperature_2m")) + put("precipitation", hourly.getJSONArray("precipitation")) + put("cloud_cover", hourly.getJSONArray("cloud_cover")) + } + + JSONObject().apply { + put("timezone", apiJson.optString("timezone", "UTC")) + put("latitude", apiJson.getDouble("latitude")) + put("longitude", apiJson.getDouble("longitude")) + put("fetchedAt", formatIso8601(System.currentTimeMillis())) + put("hourly", cachedHourly) + } + } catch (e: Exception) { + Log.e(TAG, "Error transforming API response", e) + null + } + } + + /** + * Normalize API timestamp to ISO 8601 format with milliseconds and Z suffix. + * "2023-01-01T00:00" -> "2023-01-01T00:00:00.000Z" + */ + private fun normalizeTimestamp(apiTime: String): String { + // Handle various formats the API might return + return when { + apiTime.endsWith("Z") -> apiTime + apiTime.contains(".") -> "${apiTime}Z" + apiTime.length == 16 -> "${apiTime}:00.000Z" // "2023-01-01T00:00" + apiTime.length == 19 -> "${apiTime}.000Z" // "2023-01-01T00:00:00" + else -> "${apiTime}Z" + } + } + + /** + * Format timestamp as ISO 8601 with milliseconds. + */ + private fun formatIso8601(millis: Long): String { + val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US) + format.timeZone = TimeZone.getTimeZone("UTC") + return format.format(millis) + } +} diff --git a/android/app/src/main/kotlin/org/bortnik/meteogram/WeatherUpdateWorker.kt b/android/app/src/main/kotlin/org/bortnik/meteogram/WeatherUpdateWorker.kt index eb82c79..89a0b46 100644 --- a/android/app/src/main/kotlin/org/bortnik/meteogram/WeatherUpdateWorker.kt +++ b/android/app/src/main/kotlin/org/bortnik/meteogram/WeatherUpdateWorker.kt @@ -69,8 +69,8 @@ class WeatherUpdateWorker( Log.d(TAG, "Weather data stale - fetching fresh data") WidgetUtils.fetchWeather(applicationContext) } else { - Log.d(TAG, "Weather data fresh - re-rendering all widgets") - WidgetUtils.rerenderAllWidgets(applicationContext) + Log.d(TAG, "Weather data fresh - re-rendering all widgets natively") + WidgetUtils.rerenderAllWidgetsNative(applicationContext) } return Result.success() diff --git a/android/app/src/main/kotlin/org/bortnik/meteogram/WidgetEventReceiver.kt b/android/app/src/main/kotlin/org/bortnik/meteogram/WidgetEventReceiver.kt index b46a225..b334030 100644 --- a/android/app/src/main/kotlin/org/bortnik/meteogram/WidgetEventReceiver.kt +++ b/android/app/src/main/kotlin/org/bortnik/meteogram/WidgetEventReceiver.kt @@ -76,7 +76,8 @@ class WidgetEventReceiver : BroadcastReceiver() { } private fun triggerReRender(context: Context) { - WidgetUtils.rerenderAllWidgets(context) + // Use native widget update (no Dart/Flutter involved) + WidgetUtils.rerenderAllWidgetsNative(context) } private fun isNetworkAvailable(context: Context): Boolean { diff --git a/android/app/src/main/kotlin/org/bortnik/meteogram/WidgetUtils.kt b/android/app/src/main/kotlin/org/bortnik/meteogram/WidgetUtils.kt index f628e7e..fde1011 100644 --- a/android/app/src/main/kotlin/org/bortnik/meteogram/WidgetUtils.kt +++ b/android/app/src/main/kotlin/org/bortnik/meteogram/WidgetUtils.kt @@ -1,5 +1,7 @@ package org.bortnik.meteogram +import android.appwidget.AppWidgetManager +import android.content.ComponentName import android.content.Context import android.util.Log @@ -44,62 +46,12 @@ object WidgetUtils { } /** - * Get current system locale as string for passing to Flutter. - * Format: "language_COUNTRY" (e.g., "en_US", "uk_UA") - */ - fun getLocaleString(): String { - val locale = java.util.Locale.getDefault() - return "${locale.language}_${locale.country}" - } - - /** - * Re-render chart for a specific widget with its dimensions. - */ - fun rerenderChartForWidget(context: Context, widgetId: Int, widthPx: Int, heightPx: Int) { - try { - val localeStr = getLocaleString() - - es.antonborri.home_widget.HomeWidgetBackgroundIntent.getBroadcast( - context, - android.net.Uri.parse("homewidget://chartReRender?widgetId=$widgetId&width=$widthPx&height=$heightPx&locale=$localeStr") - ).send() - Log.d(TAG, "Chart re-render triggered for widget $widgetId (${widthPx}x${heightPx}, locale=$localeStr)") - } catch (e: Exception) { - Log.e(TAG, "Failed to trigger chart re-render for widget $widgetId", e) - } - } - - /** - * Re-render charts for all widgets. - * Triggers a single background intent that will iterate through all widget IDs. - */ - fun rerenderAllWidgets(context: Context) { - try { - val localeStr = getLocaleString() - - es.antonborri.home_widget.HomeWidgetBackgroundIntent.getBroadcast( - context, - android.net.Uri.parse("homewidget://chartReRenderAll?locale=$localeStr") - ).send() - Log.d(TAG, "Chart re-render triggered for all widgets (locale=$localeStr)") - } catch (e: Exception) { - Log.e(TAG, "Failed to trigger chart re-render for all widgets", e) - } - } - - /** - * Fetch weather via HomeWidget background intent. + * Fetch weather natively via Open-Meteo API. + * Runs asynchronously and updates widget on completion. */ fun fetchWeather(context: Context) { - try { - es.antonborri.home_widget.HomeWidgetBackgroundIntent.getBroadcast( - context, - android.net.Uri.parse("homewidget://weatherUpdate") - ).send() - Log.d(TAG, "Weather fetch triggered via HomeWidget") - } catch (e: Exception) { - Log.e(TAG, "Failed to trigger weather fetch", e) - } + Log.d(TAG, "Triggering native weather fetch") + WeatherFetcher.fetchAndUpdate(context) } /** @@ -177,7 +129,43 @@ object WidgetUtils { */ fun rerenderAllWidgetsIfNeeded(context: Context) { if (isRerenderNeeded(context)) { - rerenderAllWidgets(context) + rerenderAllWidgetsNative(context) + } + } + + /** + * Trigger native widget update for all widgets. + * This calls AppWidgetManager directly, which invokes MeteogramWidgetProvider.onUpdate() + * and uses native SVG generation (no Dart/Flutter involved). + */ + fun rerenderAllWidgetsNative(context: Context) { + try { + val appWidgetManager = AppWidgetManager.getInstance(context) + val componentName = ComponentName(context, MeteogramWidgetProvider::class.java) + val widgetIds = appWidgetManager.getAppWidgetIds(componentName) + + if (widgetIds.isEmpty()) { + Log.d(TAG, "No widgets to update") + return + } + + Log.d(TAG, "Triggering native update for ${widgetIds.size} widgets") + + // notifyAppWidgetViewDataChanged triggers onUpdate + for (widgetId in widgetIds) { + appWidgetManager.notifyAppWidgetViewDataChanged(widgetId, android.R.id.list) + } + + // Also send explicit update intent + val intent = android.content.Intent(context, MeteogramWidgetProvider::class.java).apply { + action = AppWidgetManager.ACTION_APPWIDGET_UPDATE + putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, widgetIds) + } + context.sendBroadcast(intent) + + Log.d(TAG, "Native widget update triggered for ${widgetIds.joinToString()}") + } catch (e: Exception) { + Log.e(TAG, "Failed to trigger native widget update", e) } } } diff --git a/android/build.gradle.kts b/android/build.gradle.kts index dbee657..ed59d39 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -5,6 +5,26 @@ allprojects { } } +// Force all subprojects (Flutter plugins) to use Java 17 +subprojects { + afterEvaluate { + if (project.hasProperty("android")) { + extensions.configure { + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + } + } + // Also set Kotlin JVM target to 17 + tasks.withType().configureEach { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } + } + } +} + val newBuildDir: Directory = rootProject.layout.buildDirectory .dir("../../build") diff --git a/docs/HOME_WIDGET_VERSION_ISSUE.md b/docs/HOME_WIDGET_VERSION_ISSUE.md deleted file mode 100644 index b3539ad..0000000 --- a/docs/HOME_WIDGET_VERSION_ISSUE.md +++ /dev/null @@ -1,107 +0,0 @@ -# home_widget Version Issue: Background Callback Delays - -## Summary - -The `home_widget` package version 0.9.0 introduced a breaking change that affects widget resize handling. Background callbacks triggered via `HomeWidgetBackgroundIntent` are delayed or unreliable, causing widget resizes to not immediately regenerate SVG charts. - -## Root Cause - -| Version | Background Mechanism | Behavior | -|---------|---------------------|----------| -| 0.7.0+1 | `JobIntentService` | Executes immediately | -| 0.9.0 | `WorkManager` | Delayed execution (system-managed) | - -In version 0.9.0, the library switched from `JobIntentService` to `WorkManager` for background task execution. WorkManager is designed for deferrable work and includes built-in delays ("Minimum latency" constraints) that prevent immediate execution. - -## Affected Functionality - -- **Widget resize**: When user resizes the widget, `onAppWidgetOptionsChanged` triggers `WidgetUtils.triggerChartReRender()` which sends a `HomeWidgetBackgroundIntent`. With 0.9.0, the Flutter callback may not execute promptly. - -- **Periodic updates**: Similar issue affects `WeatherUpdateWorker` triggering chart re-renders. - -- **Theme/locale changes**: `WidgetEventReceiver` broadcasts may also be affected. - -## Bisect Results - -``` -First bad commit: 49c18c1fa3b9aa8a2e557dd864aa92187b887ccb - - Update dependencies to latest versions - - - geocoding: 3.0.0 → 4.0.0 - - geolocator: 13.0.4 → 14.0.2 - - home_widget: 0.7.0+1 → 0.9.0 -``` - -Tested commits: -- `5b82f63` (home_widget 0.7.0+1) - **GOOD** - Resize works immediately -- `48337e8` (home_widget 0.7.0+1) - **GOOD** - Resize works immediately -- `49c18c1` (home_widget 0.9.0) - **BAD** - Resize delayed/unreliable -- `817c817` (home_widget 0.9.0) - **BAD** - Resize delayed/unreliable - -## Resolution - -**Applied: Pinned to home_widget 0.8.0** - -Version 0.8.0 is the last version using `JobIntentService` (immediate execution). -Version 0.8.1+ switched to `WorkManager` (delayed execution). - -Changes made: -- Pinned `home_widget` to 0.8.0 in `pubspec.yaml` -- Removed `workmanager` dependency (not needed) -- Removed WorkManager-related code from `background_service.dart` - -Widget resize now works immediately. - -**Why 0.8.0 over 0.7.0+1**: Version 0.8.0 includes Android 15 runtime fix (#330) while still using JobIntentService. - -**Note on deprecation**: `JobIntentService` is deprecated (API 30) but still functional. This doesn't affect Play Store publishing (targetSdk requirements are separate from internal API usage). Monitor for future Android versions that may remove the API. - ---- - -## Alternative Workarounds (Not Used) - -### Option 1: Downgrade home_widget ✓ APPLIED - -Pin `home_widget` to version 0.7.0+1 in `pubspec.yaml`: - -```yaml -dependencies: - home_widget: 0.7.0+1 # Pinned - see docs/HOME_WIDGET_VERSION_ISSUE.md -``` - -### Option 2: Native-only resize handling - -Since SVG is vector-based, the native `MeteogramWidgetProvider` already scales the SVG to current widget dimensions when rendering. The visual result is correct even without regenerating the SVG. - -Only regenerate SVGs when: -- Weather data is fetched (already working) -- App goes to background (already working) -- Significant dimension change threshold exceeded - -### Option 3: Direct native callback - -Bypass `HomeWidgetBackgroundIntent` for resize events and use a different mechanism: -- Native broadcast receiver that triggers Flutter via method channel -- Or render charts entirely in native code - -## Technical Details - -### home_widget 0.7.0+1 (Working) - -Uses `HomeWidgetBackgroundService` extending `JobIntentService`: -- Executes immediately when broadcast received -- No system-imposed delays - -### home_widget 0.9.0 (Broken for our use case) - -Uses `HomeWidgetBackgroundWorker` with `WorkManager`: -- Jobs enqueued with `ExistingWorkPolicy.APPEND` -- System manages execution timing -- May batch or delay work for battery optimization - -## References - -- home_widget changelog: https://pub.dev/packages/home_widget/changelog -- WorkManager documentation: https://developer.android.com/topic/libraries/architecture/workmanager -- Related issue investigation: 2026-01-13 diff --git a/lib/constants.dart b/lib/constants.dart index c11b84a..3be38bf 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -1,43 +1,7 @@ // Application-wide constants. -// Centralizes magic numbers and configuration values for maintainability. /// How long weather data remains valid before being considered stale. -/// Used by foreground refresh timer and background service. const Duration kWeatherStalenessThreshold = Duration(minutes: 15); /// How often the foreground app checks for stale data and hour boundaries. const Duration kForegroundRefreshInterval = Duration(minutes: 1); - -/// Chart visual constants for SVG generation. -class ChartConstants { - /// Time label font size as ratio of chart width (4% of width). - static const double timeFontSizeRatio = 0.04; - - /// Temperature label font size as ratio of chart width (4.5% of width). - static const double tempFontSizeRatio = 0.045; - - /// Bar width as ratio of slot width (70% of available space). - static const double barWidthRatio = 0.7; - - /// Chart height as percentage of total height (95%). - static const double chartHeightRatio = 0.95; - - /// Temperature range vertical padding (10% of range). - static const double tempRangePaddingRatio = 0.10; - - /// Opacity for daylight bars. - static const double daylightBarOpacity = 0.8; - - /// Opacity for precipitation bars. - static const double precipitationBarOpacity = 0.85; -} - -/// Half-hour alarm timing constants. -/// The "now" indicator snaps to nearest hour at :30, so alarms fire then. -class AlarmConstants { - /// Buffer after half-hour boundary to ensure alarm fires after :30 (seconds). - static const int halfHourBoundaryBufferSeconds = 15; - - /// Minute threshold for "now" indicator rounding (>= this rounds up to next hour). - static const int nowIndicatorRoundingMinute = 30; -} diff --git a/lib/main.dart b/lib/main.dart index 2bc8f13..3e76e40 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,14 +4,12 @@ import 'package:flutter_localizations/flutter_localizations.dart'; import 'screens/home_screen.dart'; import 'services/widget_service.dart'; -import 'services/background_service.dart'; import 'services/material_you_service.dart'; import 'theme/app_theme.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); await WidgetService.initialize(); - await BackgroundService.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 3af08ad..07450ff 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -6,14 +6,12 @@ import 'package:url_launcher/url_launcher.dart'; import '../constants.dart'; import '../l10n/app_localizations.dart'; import '../models/weather_data.dart'; -import '../services/weather_service.dart'; import '../services/location_service.dart'; import '../services/widget_service.dart'; -import '../services/svg_chart_generator.dart'; +import '../services/native_svg_service.dart'; import '../services/units_service.dart'; import '../services/material_you_service.dart'; import '../theme/app_theme.dart'; -import '../utils/locale_utils.dart'; import '../widgets/native_svg_chart_view.dart'; /// Main home screen displaying the meteogram. @@ -30,7 +28,6 @@ class HomeScreen extends StatefulWidget { } class _HomeScreenState extends State with WidgetsBindingObserver { - final _weatherService = WeatherService(); final _locationService = LocationService(); final _widgetService = WidgetService(); @@ -41,14 +38,15 @@ class _HomeScreenState extends State with WidgetsBindingObserver { LocationSource _locationSource = LocationSource.gps; static const double _chartAspectRatio = 2.0; // Fixed 2:1 for in-app display Brightness? _lastRenderedBrightness; // Track theme for re-render on change - String _locale = 'en'; // Cached locale for widget generation - bool _usesFahrenheit = false; // Cached Fahrenheit preference bool _isUpdatingWidget = false; // Prevents concurrent widget updates bool _isLoadingWeather = false; // Prevents concurrent weather fetches - // Cached Material You colors for widget SVG generation - SvgChartColors? _materialYouLightColors; - SvgChartColors? _materialYouDarkColors; + // Cached SVG string for in-app chart display + String? _cachedSvgString; + // Parameters used to generate cached SVG (for invalidation) + int? _cachedSvgWidth; + int? _cachedSvgHeight; + bool? _cachedSvgIsLight; // Periodic timer for foreground auto-refresh (every minute) Timer? _refreshTimer; @@ -60,8 +58,6 @@ class _HomeScreenState extends State with WidgetsBindingObserver { void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); - // Initialize Material You colors immediately so widget generation uses them - _initializeMaterialYouColors(); _initialize(); // Start periodic refresh timer (checks staleness and hour boundaries) _refreshTimer = Timer.periodic(kForegroundRefreshInterval, (_) { @@ -69,25 +65,6 @@ class _HomeScreenState extends State with WidgetsBindingObserver { }); } - /// Initialize Material You colors from native extraction. - /// Called in initState so colors are available before first build. - void _initializeMaterialYouColors() { - if (widget.materialYouColors != null) { - // Use native colors directly for widget SVG generation - // Light mode: onPrimaryContainer for temperature (darker, better contrast) - // Dark mode: primary for temperature (brighter, better contrast) - // Must match background_service.dart color selection - _materialYouLightColors = SvgChartColors.light.withDynamicColors( - temperatureLine: SvgColor.fromArgb(widget.materialYouColors!.light.onPrimaryContainer.toARGB32()), - timeLabel: SvgColor.fromArgb(widget.materialYouColors!.light.tertiary.toARGB32()), - ); - _materialYouDarkColors = SvgChartColors.dark.withDynamicColors( - temperatureLine: SvgColor.fromArgb(widget.materialYouColors!.dark.primary.toARGB32()), - timeLabel: SvgColor.fromArgb(widget.materialYouColors!.dark.tertiary.toARGB32()), - ); - } - } - /// Combined initialization: load dimensions first, then data. /// Ensures chart renders with correct aspect ratio from the start. Future _initialize() async { @@ -107,9 +84,6 @@ class _HomeScreenState extends State with WidgetsBindingObserver { final locale = platformLocale.toString(); final usesFahrenheit = UnitsService.usesFahrenheit(platformLocale); - _locale = locale; - _usesFahrenheit = usesFahrenheit; - await HomeWidget.saveWidgetData('locale', locale); await HomeWidget.saveWidgetData('usesFahrenheit', usesFahrenheit); @@ -120,10 +94,10 @@ class _HomeScreenState extends State with WidgetsBindingObserver { /// Shows cached data immediately while fetching fresh in background. Future _initializeData() async { // First, immediately show cached data if available (same as widget) - final cached = await _weatherService.getCachedWeather(); + final cached = await NativeSvgService.getCachedWeather(); if (cached != null) { - final cachedCity = await _weatherService.getCachedCityName(); - final cachedSource = await _weatherService.getCachedLocationSource(); + final cachedCity = await NativeSvgService.getCachedCityName(); + final cachedSource = await NativeSvgService.getCachedLocationSource(); setState(() { _weatherData = cached; _locationName = cachedCity; @@ -135,6 +109,7 @@ class _HomeScreenState extends State with WidgetsBindingObserver { } _loading = false; _lastDisplayHour = DateTime.now().minute >= 30 ? (DateTime.now().hour + 1) % 24 : DateTime.now().hour; + _cachedSvgString = null; // Invalidate cache for new data }); debugPrint('Showing cached data immediately: ${cached.fetchedAt}'); } @@ -151,7 +126,6 @@ class _HomeScreenState extends State with WidgetsBindingObserver { @override void dispose() { _refreshTimer?.cancel(); - _weatherService.dispose(); _locationService.dispose(); WidgetsBinding.instance.removeObserver(this); super.dispose(); @@ -171,6 +145,7 @@ class _HomeScreenState extends State with WidgetsBindingObserver { if (_lastDisplayHour != null && _lastDisplayHour != displayHour) { debugPrint('Half-hour boundary crossed ($_lastDisplayHour -> $displayHour), redrawing chart...'); _lastDisplayHour = displayHour; + _cachedSvgString = null; // Invalidate cache to regenerate with new time setState(() {}); // Trigger rebuild to update "now" indicator position } _lastDisplayHour ??= displayHour; @@ -195,29 +170,22 @@ class _HomeScreenState extends State with WidgetsBindingObserver { } /// Update widget when app goes to background. - /// Regenerates SVG with current time position. + /// Triggers native widget update which generates fresh SVG. Future _updateWidgetOnBackground() async { if (_weatherData == null) return; try { - // Generate fresh SVG charts with current time and Material You colors - await _widgetService.generateAndSaveSvgCharts( - displayData: _weatherData!.getDisplayRange(), - nowIndex: _weatherData!.getNowIndex(), - latitude: _weatherData!.latitude, - longitude: _weatherData!.longitude, - locale: _locale, - usesFahrenheit: _usesFahrenheit, - lightColors: _materialYouLightColors, - darkColors: _materialYouDarkColors, - ); + // Save current data for widget display + final currentHour = _weatherData!.getCurrentHour(); + if (currentHour != null) { + await _widgetService.saveCurrentTemperature( + UnitsService.formatTemperature(currentHour.temperature, PlatformDispatcher.instance.locale), + ); + } + await _widgetService.saveLocationName(_locationName); - // Trigger widget update to load new SVGs - await _widgetService.updateWidget( - weatherData: _weatherData!, - locationName: _locationName, - locale: LocaleUtils.parseLocaleString(_locale), - ); + // Trigger native widget update - SVG is generated natively + await _widgetService.triggerWidgetUpdate(); debugPrint('Widget updated on app background'); } catch (e) { @@ -228,8 +196,9 @@ class _HomeScreenState extends State with WidgetsBindingObserver { @override void didChangePlatformBrightness() { super.didChangePlatformBrightness(); - // Theme changed while app is running - trigger native widget update to show indicator + // Theme changed while app is running - invalidate cached SVG and trigger widget update debugPrint('Platform brightness changed - triggering widget update'); + _cachedSvgString = null; // Invalidate cache to regenerate with new theme _widgetService.triggerWidgetUpdate(); // Then re-render after short delay Future.delayed(const Duration(milliseconds: 300), () { @@ -239,30 +208,6 @@ class _HomeScreenState extends State with WidgetsBindingObserver { }); } - - /// Update cached Material You colors for widget SVG generation. - /// Called from build() when context is available. - void _updateMaterialYouColors(BuildContext context) { - // Use native colors directly for widget SVG generation - // Must match _initializeMaterialYouColors() and background_service.dart - if (widget.materialYouColors != null) { - // Light mode: onPrimaryContainer for temperature (darker, better contrast) - // Dark mode: primary for temperature (brighter, better contrast) - _materialYouLightColors = SvgChartColors.light.withDynamicColors( - temperatureLine: SvgColor.fromArgb(widget.materialYouColors!.light.onPrimaryContainer.toARGB32()), - timeLabel: SvgColor.fromArgb(widget.materialYouColors!.light.tertiary.toARGB32()), - ); - _materialYouDarkColors = SvgChartColors.dark.withDynamicColors( - temperatureLine: SvgColor.fromArgb(widget.materialYouColors!.dark.primary.toARGB32()), - timeLabel: SvgColor.fromArgb(widget.materialYouColors!.dark.tertiary.toARGB32()), - ); - } else { - // Fall back to default colors when Material You not available - _materialYouLightColors = SvgChartColors.light; - _materialYouDarkColors = SvgChartColors.dark; - } - } - /// Quick check on startup to sync widget state with cache age. /// Also handles widget resize and theme changes by re-rendering. Future _checkAndSyncWidget() async { @@ -277,13 +222,13 @@ class _HomeScreenState extends State with WidgetsBindingObserver { } // Check if cache has newer data than in-memory (background service may have updated) - final cached = await _weatherService.getCachedWeather(); + final cached = await NativeSvgService.getCachedWeather(); final cacheIsNewer = cached != null && (_weatherData == null || cached.fetchedAt.isAfter(_weatherData!.fetchedAt)); - final isStale = await _weatherService.isCacheStale(); + final isStale = await NativeSvgService.isCacheStale(); if (isStale || wasResized || themeChanged || cacheIsNewer) { - final cachedCity = await _weatherService.getCachedCityName(); + final cachedCity = await NativeSvgService.getCachedCityName(); if (cached != null) { if (cacheIsNewer) { debugPrint('Cache is newer than in-memory data, syncing: ${cached.fetchedAt} > ${_weatherData?.fetchedAt}'); @@ -292,6 +237,7 @@ class _HomeScreenState extends State with WidgetsBindingObserver { _weatherData = cached; _locationName = cachedCity; _lastDisplayHour = DateTime.now().minute >= 30 ? (DateTime.now().hour + 1) % 24 : DateTime.now().hour; + _cachedSvgString = null; // Invalidate cache for new data }); // Update widget after frame is rendered WidgetsBinding.instance.addPostFrameCallback((_) async { @@ -315,22 +261,17 @@ class _HomeScreenState extends State with WidgetsBindingObserver { // Track brightness for theme change detection _lastRenderedBrightness = WidgetsBinding.instance.platformDispatcher.platformBrightness; - await _widgetService.updateWidget( - weatherData: weather, - locationName: _locationName, - locale: LocaleUtils.parseLocaleString(_locale), - ); - // Also generate SVG charts for background widget updates - await _widgetService.generateAndSaveSvgCharts( - displayData: weather.getDisplayRange(), - nowIndex: weather.getNowIndex(), - latitude: weather.latitude, - longitude: weather.longitude, - locale: _locale, - usesFahrenheit: _usesFahrenheit, - lightColors: _materialYouLightColors, - darkColors: _materialYouDarkColors, - ); + // Save current data for widget display + final currentHour = weather.getCurrentHour(); + if (currentHour != null) { + await _widgetService.saveCurrentTemperature( + UnitsService.formatTemperature(currentHour.temperature, PlatformDispatcher.instance.locale), + ); + } + await _widgetService.saveLocationName(_locationName); + + // Trigger native widget update - SVG is generated natively from cached weather + await _widgetService.triggerWidgetUpdate(); } finally { _isUpdatingWidget = false; } @@ -357,21 +298,34 @@ class _HomeScreenState extends State with WidgetsBindingObserver { try { final location = await _locationService.getLocation(); - final weather = await _weatherService.fetchWeather( - location.latitude, - location.longitude, + + // Fetch weather via native Kotlin HTTP client + final success = await NativeSvgService.fetchWeather( + latitude: location.latitude, + longitude: location.longitude, ); + if (!success) { + throw Exception('Failed to fetch weather data'); + } + + // Read the cached weather data + final weather = await NativeSvgService.getCachedWeather(); + if (weather == null) { + throw Exception('Weather data not available after fetch'); + } + setState(() { _weatherData = weather; _locationName = location.city; _locationSource = location.source; _loading = false; _lastDisplayHour = DateTime.now().minute >= 30 ? (DateTime.now().hour + 1) % 24 : DateTime.now().hour; + _cachedSvgString = null; // Invalidate cache for new data }); // Cache location info for offline use - await _weatherService.cacheLocationInfo(location.city, _locationSource.name); + await NativeSvgService.cacheLocationInfo(location.city, _locationSource.name); // Update widget after frame is rendered WidgetsBinding.instance.addPostFrameCallback((_) async { @@ -379,10 +333,10 @@ class _HomeScreenState extends State with WidgetsBindingObserver { }); } catch (e) { // Try to use cached weather data on any failure - final cached = await _weatherService.getCachedWeather(); + final cached = await NativeSvgService.getCachedWeather(); if (cached != null) { - final cachedCity = await _weatherService.getCachedCityName(); - final cachedSource = await _weatherService.getCachedLocationSource(); + final cachedCity = await NativeSvgService.getCachedCityName(); + final cachedSource = await NativeSvgService.getCachedLocationSource(); setState(() { _weatherData = cached; _locationName = cachedCity; @@ -394,6 +348,7 @@ class _HomeScreenState extends State with WidgetsBindingObserver { } _loading = false; _lastDisplayHour = DateTime.now().minute >= 30 ? (DateTime.now().hour + 1) % 24 : DateTime.now().hour; + _cachedSvgString = null; // Invalidate cache for new data }); // Update widget with cached data @@ -410,11 +365,7 @@ class _HomeScreenState extends State with WidgetsBindingObserver { // No cache available, show error setState(() { - if (e is WeatherException) { - _error = e.message; - } else { - _error = e.toString(); - } + _error = e.toString(); _loading = false; }); } finally { @@ -440,14 +391,40 @@ class _HomeScreenState extends State with WidgetsBindingObserver { return isDark ? widget.materialYouColors!.dark : widget.materialYouColors!.light; } + /// Generate SVG asynchronously using native Kotlin generator. + /// Updates cached SVG and triggers rebuild when complete. + Future _generateSvgAsync({ + required int width, + required int height, + required bool isLight, + required bool usesFahrenheit, + }) async { + try { + final svgString = await NativeSvgService.generateSvg( + width: width, + height: height, + isLight: isLight, + usesFahrenheit: usesFahrenheit, + ); + + if (svgString != null && mounted) { + setState(() { + _cachedSvgString = svgString; + _cachedSvgWidth = width; + _cachedSvgHeight = height; + _cachedSvgIsLight = isLight; + }); + } + } catch (e) { + debugPrint('Error generating SVG: $e'); + } + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; final colors = MeteogramColors.of(context, nativeColors: _getNativeColorsForTheme(context)); - // Update cached Material You colors for widget SVG generation - _updateMaterialYouColors(context); - return Scaffold( backgroundColor: colors.background, body: SafeArea( @@ -547,8 +524,6 @@ class _HomeScreenState extends State with WidgetsBindingObserver { ); } - final displayData = _weatherData!.getDisplayRange(); - final nowIndex = _weatherData!.getNowIndex(); final currentHour = _weatherData!.getCurrentHour(); return RefreshIndicator( @@ -673,51 +648,51 @@ class _HomeScreenState extends State with WidgetsBindingObserver { final isLight = Theme.of(context).brightness == Brightness.light; final mediaQuery = MediaQuery.of(context); final dpr = mediaQuery.devicePixelRatio; - // Get locale for time formatting and temperature units - // Use platform locale (not Flutter's resolved locale) to get country code for unit preferences + // Get locale for temperature units final platformLocale = PlatformDispatcher.instance.locale; - final locale = platformLocale.toString(); final usesFahrenheit = UnitsService.usesFahrenheit(platformLocale); - _locale = locale; - _usesFahrenheit = usesFahrenheit; // Save for background service - HomeWidget.saveWidgetData('locale', locale); + HomeWidget.saveWidgetData('locale', platformLocale.toString()); HomeWidget.saveWidgetData('usesFahrenheit', usesFahrenheit); // Generate SVG at device pixel dimensions - final deviceWidth = chartWidth * dpr; - final deviceHeight = chartHeight * dpr; - - // Apply Material You dynamic colors - final meteogramColors = MeteogramColors.of(context, nativeColors: _getNativeColorsForTheme(context)); - final baseColors = isLight ? SvgChartColors.light : SvgChartColors.dark; - final colors = baseColors.withDynamicColors( - temperatureLine: SvgColor.fromArgb(meteogramColors.temperatureLine.toARGB32()), - timeLabel: SvgColor.fromArgb(meteogramColors.timeLabel.toARGB32()), - ); - - final generator = SvgChartGenerator(); - final svgString = generator.generate( - data: displayData, - nowIndex: nowIndex, - latitude: _weatherData!.latitude, - longitude: _weatherData!.longitude, - colors: colors, - width: deviceWidth, - height: deviceHeight, - locale: locale, - usesFahrenheit: usesFahrenheit, - ); - - return SizedBox( - width: chartWidth, - height: chartHeight, - child: NativeSvgChartView( - svgString: svgString, - width: deviceWidth, - height: deviceHeight, - ), - ); + final deviceWidthPx = (chartWidth * dpr).round(); + final deviceHeightPx = (chartHeight * dpr).round(); + + // Check if we need to regenerate the SVG + final needsRegeneration = _cachedSvgString == null || + _cachedSvgWidth != deviceWidthPx || + _cachedSvgHeight != deviceHeightPx || + _cachedSvgIsLight != isLight; + + if (needsRegeneration) { + // Trigger async SVG generation + _generateSvgAsync( + width: deviceWidthPx, + height: deviceHeightPx, + isLight: isLight, + usesFahrenheit: usesFahrenheit, + ); + } + + // Show cached SVG if available, otherwise show empty container + if (_cachedSvgString != null) { + return SizedBox( + width: chartWidth, + height: chartHeight, + child: NativeSvgChartView( + svgString: _cachedSvgString!, + width: deviceWidthPx.toDouble(), + height: deviceHeightPx.toDouble(), + ), + ); + } else { + // Show placeholder while generating + return SizedBox( + width: chartWidth, + height: chartHeight, + ); + } }, ), ], diff --git a/lib/services/background_service.dart b/lib/services/background_service.dart deleted file mode 100644 index 4b81234..0000000 --- a/lib/services/background_service.dart +++ /dev/null @@ -1,472 +0,0 @@ -import 'dart:convert'; -import 'dart:developer' as developer; -import 'dart:io'; -import 'package:flutter/material.dart'; -import 'package:home_widget/home_widget.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:intl/date_symbol_data_local.dart'; -import '../constants.dart'; -import '../utils/locale_utils.dart'; -import 'weather_service.dart'; -import 'location_service.dart'; -import 'svg_chart_generator.dart'; -import 'units_service.dart'; -import 'widget_service.dart' show kLightSvgFileName, kDarkSvgFileName; -import '../models/weather_data.dart'; - -void _log(String message) { - developer.log(message, name: 'BackgroundService'); -} - -/// Get current system locale from Platform.localeName. -/// Returns Locale with language and country code (e.g., en_US -> Locale('en', 'US')) -Locale _getSystemLocale() { - final localeName = Platform.localeName; - _log('System locale: $localeName'); - return LocaleUtils.getSystemLocale(); -} - -// Default fallback dimensions - must match WidgetUtils.kt -const int kDefaultWidthPx = 1000; -const int kDefaultHeightPx = 500; - -/// Background callback for HomeWidget (handles native events) -@pragma('vm:entry-point') -Future homeWidgetBackgroundCallback(Uri? uri) async { - // Initialize Flutter bindings for headless execution - WidgetsFlutterBinding.ensureInitialized(); - _log('homeWidgetBackgroundCallback called with uri: $uri'); - - if (uri == null) { - _log('homeWidgetBackgroundCallback: uri is null'); - return; - } - - _log('homeWidgetBackgroundCallback: host=${uri.host}'); - // Note: URI host is always lowercase - switch (uri.host.toLowerCase()) { - case 'weatherupdate': - // Fetch weather data if stale - _log('homeWidgetBackgroundCallback: executing weatherUpdate'); - await _updateWeatherData(); - break; - case 'chartrerender': - // Re-render charts from cached data (no network call) - // Dimensions may be passed in URI query params for cold-start reliability - // Optional widgetId param targets a specific widget - _log('homeWidgetBackgroundCallback: executing chartReRender'); - await _reRenderCharts(uri); - break; - case 'chartrerenderall': - // Re-render charts for all widgets (iterate through widget IDs) - _log('homeWidgetBackgroundCallback: executing chartReRenderAll'); - await _reRenderAllWidgets(uri); - break; - default: - _log('homeWidgetBackgroundCallback: unknown host ${uri.host}'); - } -} - -/// Update weather data in background -Future _updateWeatherData() async { - _log('_updateWeatherData started'); - - // Initialize locale data for DateFormat (required in background isolate) - await initializeDateFormatting(); - - final locationService = LocationService(); - final weatherService = WeatherService(); - - try { - _log('Getting location...'); - // Try HomeWidget storage first (more reliable in background isolates) - // Falls back to SharedPreferences via getLocation() if not available - var location = await locationService.getSavedLocationFromWidget(); - if (location != null) { - _log('Using saved location from HomeWidget: ${location.latitude}, ${location.longitude} (${location.city})'); - } else { - location = await locationService.getLocation(); - _log('Location from getLocation(): ${location.latitude}, ${location.longitude} (${location.city})'); - } - - _log('Fetching weather...'); - final weather = await weatherService.fetchWeatherWithRetry( - location.latitude, - location.longitude, - ); - _log('Weather fetched: ${weather.hourly.length} hours'); - - // Cache weather data and location for re-rendering - await HomeWidget.saveWidgetData( - 'cached_weather', - jsonEncode(weather.toJson()), - ); - await HomeWidget.saveWidgetData('cached_latitude', location.latitude); - await HomeWidget.saveWidgetData('cached_longitude', location.longitude); - - // Save timestamp for staleness checks - await HomeWidget.saveWidgetData( - 'last_weather_update', - DateTime.now().millisecondsSinceEpoch, - ); - _log('Cached weather data and timestamp'); - - // Get current system locale for temperature formatting - final systemLocale = _getSystemLocale(); - final usesFahrenheit = UnitsService.usesFahrenheit(systemLocale); - _log('Using locale: ${systemLocale.toLanguageTag()}, usesFahrenheit: $usesFahrenheit'); - - final currentHour = weather.getCurrentHour(); - final tempString = currentHour != null - ? UnitsService.formatTemperatureFromBool(currentHour.temperature, usesFahrenheit) - : '--°'; - - await HomeWidget.saveWidgetData('current_temperature', tempString); - await HomeWidget.saveWidgetData('location_name', location.city ?? ''); - _log('Saved temperature: $tempString'); - - // Generate SVG charts for all widgets - _log('Generating SVG charts...'); - final widgetIdsStr = await HomeWidget.getWidgetData('widget_ids'); - if (widgetIdsStr != null && widgetIdsStr.isNotEmpty) { - final widgetIds = widgetIdsStr.split(',').map((s) => int.tryParse(s.trim())).whereType().toList(); - _log('Generating SVGs for ${widgetIds.length} widgets: $widgetIds'); - for (final widgetId in widgetIds) { - final widthPx = await HomeWidget.getWidgetData('widget_${widgetId}_width_px'); - final heightPx = await HomeWidget.getWidgetData('widget_${widgetId}_height_px'); - await _generateSvgCharts(weather, location.latitude, location.longitude, uriWidth: widthPx, uriHeight: heightPx, widgetId: widgetId); - } - } else { - // No widget IDs tracked yet, generate generic SVG for backward compatibility - await _generateSvgCharts(weather, location.latitude, location.longitude); - } - _log('SVG charts generated'); - - // Update last render time for conditional re-render on unlock - await HomeWidget.saveWidgetData('last_render_time', DateTime.now().millisecondsSinceEpoch); - - _log('Updating widget...'); - await HomeWidget.updateWidget( - androidName: 'MeteogramWidgetProvider', - iOSName: 'MeteogramWidget', - ); - _log('Widget updated successfully'); - } catch (e, stack) { - _log('_updateWeatherData failed: $e\n$stack'); - rethrow; // Propagate error so WorkManager knows task failed and can retry - } -} - -/// Re-render charts from cached weather data (no network call). -/// Used for locale/timezone/theme changes where data doesn't need refreshing. -/// Dimensions and locale can be passed in URI query params for cold-start reliability. -/// If widgetId is provided, only renders for that specific widget. -/// If cached data is stale (>15 min old), fetches fresh data instead. -Future _reRenderCharts([Uri? uri]) async { - _log('_reRenderCharts started with uri: $uri'); - - // Extract params from URI if provided (more reliable than SharedPreferences/Platform in cold-start) - int? uriWidth; - int? uriHeight; - String? uriLocale; - int? widgetId; - if (uri != null) { - uriWidth = int.tryParse(uri.queryParameters['width'] ?? ''); - uriHeight = int.tryParse(uri.queryParameters['height'] ?? ''); - uriLocale = uri.queryParameters['locale']; - widgetId = int.tryParse(uri.queryParameters['widgetId'] ?? ''); - _log('_reRenderCharts: widgetId=$widgetId, dimensions=${uriWidth}x$uriHeight, locale=$uriLocale'); - } - - try { - // Initialize locale data for DateFormat (required in background isolate) - await initializeDateFormatting(); - - // Check if cached data is stale (>15 minutes old) - final lastUpdate = await HomeWidget.getWidgetData('last_weather_update') ?? 0; - final ageMs = DateTime.now().millisecondsSinceEpoch - lastUpdate; - final staleThresholdMs = kWeatherStalenessThreshold.inMilliseconds; - - if (ageMs > staleThresholdMs) { - _log('_reRenderCharts: cached data is stale (${ageMs ~/ 60000} min old), fetching fresh data'); - await _updateWeatherData(); - return; - } - - // Load cached weather data - final cachedJson = await HomeWidget.getWidgetData('cached_weather'); - if (cachedJson == null) { - _log('_reRenderCharts: no cached weather data, fetching fresh'); - await _updateWeatherData(); - return; - } - - final weather = WeatherData.fromJson(jsonDecode(cachedJson) as Map); - final latitude = await HomeWidget.getWidgetData('cached_latitude') ?? 0.0; - final longitude = await HomeWidget.getWidgetData('cached_longitude') ?? 0.0; - final nowIndex = weather.getNowIndex(); - _log('_reRenderCharts: loaded weather with ${weather.hourly.length} hours, nowIndex=$nowIndex (${ageMs ~/ 60000} min old)'); - - // Regenerate SVG charts (pass URI params if available) - await _generateSvgCharts(weather, latitude, longitude, uriWidth: uriWidth, uriHeight: uriHeight, uriLocale: uriLocale, widgetId: widgetId); - - // Update last render time for conditional re-render on unlock - await HomeWidget.saveWidgetData('last_render_time', DateTime.now().millisecondsSinceEpoch); - - // Update widget - await HomeWidget.updateWidget( - androidName: 'MeteogramWidgetProvider', - iOSName: 'MeteogramWidget', - ); - _log('_reRenderCharts: widget updated'); - } catch (e, stack) { - _log('_reRenderCharts failed: $e\n$stack'); - rethrow; // Propagate error for proper failure handling - } -} - -/// Re-render charts for all widgets (e.g., after Material You color change). -/// Reads widget IDs from storage and generates SVG for each. -Future _reRenderAllWidgets([Uri? uri]) async { - _log('_reRenderAllWidgets started with uri: $uri'); - - // Extract locale from URI if provided - String? uriLocale; - if (uri != null) { - uriLocale = uri.queryParameters['locale']; - _log('_reRenderAllWidgets: locale=$uriLocale'); - } - - try { - // Initialize locale data for DateFormat (required in background isolate) - await initializeDateFormatting(); - - // Check if cached data is stale - final lastUpdate = await HomeWidget.getWidgetData('last_weather_update') ?? 0; - final ageMs = DateTime.now().millisecondsSinceEpoch - lastUpdate; - final staleThresholdMs = kWeatherStalenessThreshold.inMilliseconds; - - if (ageMs > staleThresholdMs) { - _log('_reRenderAllWidgets: cached data is stale, fetching fresh data'); - await _updateWeatherData(); - return; - } - - // Load cached weather data - final cachedJson = await HomeWidget.getWidgetData('cached_weather'); - if (cachedJson == null) { - _log('_reRenderAllWidgets: no cached weather data, fetching fresh'); - await _updateWeatherData(); - return; - } - - final weather = WeatherData.fromJson(jsonDecode(cachedJson) as Map); - final latitude = await HomeWidget.getWidgetData('cached_latitude') ?? 0.0; - final longitude = await HomeWidget.getWidgetData('cached_longitude') ?? 0.0; - - // Get list of widget IDs (stored as comma-separated string by native code) - final widgetIdsStr = await HomeWidget.getWidgetData('widget_ids'); - if (widgetIdsStr == null || widgetIdsStr.isEmpty) { - _log('_reRenderAllWidgets: no widget IDs found, generating generic SVG'); - await _generateSvgCharts(weather, latitude, longitude, uriLocale: uriLocale); - } else { - final widgetIds = widgetIdsStr.split(',').map((s) => int.tryParse(s.trim())).whereType().toList(); - _log('_reRenderAllWidgets: rendering for ${widgetIds.length} widgets: $widgetIds'); - - for (final widgetId in widgetIds) { - // Get per-widget dimensions from storage - final widthPx = await HomeWidget.getWidgetData('widget_${widgetId}_width_px'); - final heightPx = await HomeWidget.getWidgetData('widget_${widgetId}_height_px'); - _log('_reRenderAllWidgets: widget $widgetId dimensions=${widthPx}x$heightPx'); - - await _generateSvgCharts( - weather, - latitude, - longitude, - uriWidth: widthPx, - uriHeight: heightPx, - uriLocale: uriLocale, - widgetId: widgetId, - ); - } - } - - // Update last render time for conditional re-render on unlock - await HomeWidget.saveWidgetData('last_render_time', DateTime.now().millisecondsSinceEpoch); - - // Update widget - await HomeWidget.updateWidget( - androidName: 'MeteogramWidgetProvider', - iOSName: 'MeteogramWidget', - ); - _log('_reRenderAllWidgets: widget updated'); - } catch (e, stack) { - _log('_reRenderAllWidgets failed: $e\n$stack'); - rethrow; - } -} - -/// Generate SVG chart images for the widget. -/// Optional uriWidth/uriHeight/uriLocale can be passed for cold-start reliability. -/// If widgetId is provided, saves widget-specific SVG files (e.g., meteogram_light_42.svg). -Future _generateSvgCharts(WeatherData weather, double latitude, double longitude, {int? uriWidth, int? uriHeight, String? uriLocale, int? widgetId}) async { - try { - final generator = SvgChartGenerator(); - final displayData = weather.getDisplayRange(); - final nowIndex = weather.getNowIndex(); - - // Get widget dimensions - prefer URI params (reliable in cold-start), - // fall back to SharedPreferences, then defaults - var widthPx = uriWidth ?? await HomeWidget.getWidgetData('widget_width_px') ?? 0; - var heightPx = uriHeight ?? await HomeWidget.getWidgetData('widget_height_px') ?? 0; - // Ensure valid dimensions (0 means not set) - if (widthPx <= 0) widthPx = kDefaultWidthPx; - if (heightPx <= 0) heightPx = kDefaultHeightPx; - _log('_generateSvgCharts: using dimensions=${widthPx}x$heightPx (uri=${uriWidth}x$uriHeight)'); - - // Get locale - prefer URI param, then HomeWidget storage, fallback to Platform.localeName - Locale systemLocale; - bool? storedUsesFahrenheit; - if (uriLocale != null && uriLocale.isNotEmpty) { - // Parse locale from URI (format: "en_US" or "uk_UA") - final parts = uriLocale.split('_').where((p) => p.isNotEmpty).toList(); - if (parts.length >= 2) { - systemLocale = Locale(parts[0], parts[1].toUpperCase()); - } else if (parts.isNotEmpty) { - systemLocale = Locale(parts[0]); - } else { - systemLocale = const Locale('en'); - } - _log('_generateSvgCharts: using URI locale: $uriLocale -> $systemLocale'); - } else { - // Try HomeWidget storage first (saved by app at startup) - final storedLocale = await HomeWidget.getWidgetData('locale'); - storedUsesFahrenheit = await HomeWidget.getWidgetData('usesFahrenheit'); - if (storedLocale != null && storedLocale.isNotEmpty) { - systemLocale = LocaleUtils.parseLocaleString(storedLocale); - _log('_generateSvgCharts: using stored locale: $storedLocale -> $systemLocale'); - } else { - systemLocale = _getSystemLocale(); - _log('_generateSvgCharts: using Platform locale: $systemLocale'); - } - } - final locale = systemLocale.toLanguageTag(); - // Use stored value if available (more reliable), fallback to computing from locale - final usesFahrenheit = storedUsesFahrenheit ?? UnitsService.usesFahrenheit(systemLocale); - _log('_generateSvgCharts: locale=$locale, usesFahrenheit=$usesFahrenheit'); - - // Get native-extracted Material You colors directly from storage - // Must use the SAME native colors as the app (not derived from ColorScheme.fromSeed) - // App's AppTheme overrides ColorScheme with native values, so we must do the same - final lightOnPrimaryContainer = await HomeWidget.getWidgetData('material_you_light_on_primary_container'); - final lightTertiary = await HomeWidget.getWidgetData('material_you_light_tertiary'); - final darkPrimary = await HomeWidget.getWidgetData('material_you_dark_primary'); - final darkTertiary = await HomeWidget.getWidgetData('material_you_dark_tertiary'); - - // Apply native Material You colors to SVG chart colors - SvgChartColors lightColors = SvgChartColors.light; - SvgChartColors darkColors = SvgChartColors.dark; - - // Light mode: temperature uses onPrimaryContainer (darker, better contrast) - // Must match MeteogramColors.fromNativeColors() which uses colorScheme.onPrimaryContainer - if (lightOnPrimaryContainer != null && lightTertiary != null) { - lightColors = SvgChartColors.light.withDynamicColors( - temperatureLine: SvgColor.fromArgb(lightOnPrimaryContainer), - timeLabel: SvgColor.fromArgb(lightTertiary), - ); - } - - // Dark mode: temperature uses primary (brighter, better contrast) - // Must match MeteogramColors.fromNativeColors() which uses colorScheme.primary - if (darkPrimary != null && darkTertiary != null) { - darkColors = SvgChartColors.dark.withDynamicColors( - temperatureLine: SvgColor.fromArgb(darkPrimary), - timeLabel: SvgColor.fromArgb(darkTertiary), - ); - } - - // Generate light and dark theme SVGs - final svgLight = generator.generate( - data: displayData, - nowIndex: nowIndex, - latitude: latitude, - longitude: longitude, - colors: lightColors, - width: widthPx.toDouble(), - height: heightPx.toDouble(), - locale: locale, - usesFahrenheit: usesFahrenheit, - ); - - final svgDark = generator.generate( - data: displayData, - nowIndex: nowIndex, - latitude: latitude, - longitude: longitude, - colors: darkColors, - width: widthPx.toDouble(), - height: heightPx.toDouble(), - locale: locale, - usesFahrenheit: usesFahrenheit, - ); - - // Save SVG files to app documents directory using atomic writes - // (write to temp file, then rename to avoid race conditions with native reader) - final docsDir = await getApplicationDocumentsDirectory(); - - // Use widget-specific file names if widgetId provided, otherwise generic names - final lightFileName = widgetId != null ? 'meteogram_light_$widgetId.svg' : kLightSvgFileName; - final darkFileName = widgetId != null ? 'meteogram_dark_$widgetId.svg' : kDarkSvgFileName; - final lightPath = '${docsDir.path}/$lightFileName'; - final darkPath = '${docsDir.path}/$darkFileName'; - final lightTempPath = '$lightPath.tmp'; - final darkTempPath = '$darkPath.tmp'; - - _log('Writing SVG files to $lightPath (widgetId=$widgetId)'); - - // Write to temp files first - await File(lightTempPath).writeAsString(svgLight); - await File(darkTempPath).writeAsString(svgDark); - - // Atomic rename to final paths - await File(lightTempPath).rename(lightPath); - await File(darkTempPath).rename(darkPath); - - _log('SVG files written successfully'); - - // Store paths for native widget to read - // Use widget-specific keys if widgetId provided - if (widgetId != null) { - await HomeWidget.saveWidgetData('svg_path_light_$widgetId', lightPath); - await HomeWidget.saveWidgetData('svg_path_dark_$widgetId', darkPath); - } else { - // Generic paths for backward compatibility - await HomeWidget.saveWidgetData('svg_path_light', lightPath); - await HomeWidget.saveWidgetData('svg_path_dark', darkPath); - } - } catch (e, stack) { - _log('_generateSvgCharts failed: $e\n$stack'); - - // Clean up orphaned temp files on failure - try { - final docsDir = await getApplicationDocumentsDirectory(); - final lightFileName = widgetId != null ? 'meteogram_light_$widgetId.svg' : kLightSvgFileName; - final darkFileName = widgetId != null ? 'meteogram_dark_$widgetId.svg' : kDarkSvgFileName; - await File('${docsDir.path}/$lightFileName.tmp').delete(); - await File('${docsDir.path}/$darkFileName.tmp').delete(); - } catch (_) { - // Ignore cleanup errors (files may not exist) - } - - rethrow; // Propagate error so caller knows chart generation failed - } -} - -/// Initialize background service -class BackgroundService { - static Future initialize() async { - // Register HomeWidget background callback for native event handling - // Note: Periodic weather updates are handled by native WeatherUpdateWorker (WorkManager) - await HomeWidget.registerInteractivityCallback(homeWidgetBackgroundCallback); - } -} diff --git a/lib/services/native_svg_renderer.dart b/lib/services/native_svg_renderer.dart deleted file mode 100644 index 16e3d98..0000000 --- a/lib/services/native_svg_renderer.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart'; - -/// Renders SVG to bitmap using native platform renderer (AndroidSVG on Android). -/// This ensures exact visual match between widget and app. -class NativeSvgRenderer { - static const _channel = MethodChannel('org.bortnik.meteogram/svg'); - - /// Render SVG string to PNG bitmap bytes. - /// Returns null if rendering fails. - static Future renderSvgToPng({ - required String svgString, - required int width, - required int height, - }) async { - try { - final result = await _channel.invokeMethod('renderSvg', { - 'svg': svgString, - 'width': width, - 'height': height, - }); - return result; - } on PlatformException catch (e) { - debugPrint('Native SVG render error: ${e.message}'); - return null; - } - } -} diff --git a/lib/services/native_svg_service.dart b/lib/services/native_svg_service.dart new file mode 100644 index 0000000..279cf80 --- /dev/null +++ b/lib/services/native_svg_service.dart @@ -0,0 +1,135 @@ +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:home_widget/home_widget.dart'; +import '../models/weather_data.dart'; + +/// Service for native Kotlin operations via method channel. +/// Handles SVG generation, weather fetching, and cache management. +class NativeSvgService { + static const _channel = MethodChannel('org.bortnik.meteogram/svg'); + + // Cache keys (must match Kotlin WeatherFetcher constants) + static const _keyCachedWeather = 'cached_weather'; + static const _keyLastWeatherUpdate = 'last_weather_update'; + static const _keyCachedCityName = 'cached_city_name'; + static const _keyCachedLocationSource = 'cached_location_source'; + + /// Staleness threshold for weather data (15 minutes) + static const _staleThreshold = Duration(minutes: 15); + + // ============ Weather Fetching ============ + + /// Fetch weather data via native Kotlin HTTP client. + /// Weather is saved to SharedPreferences for later use. + /// Returns true on success, false on failure. + static Future fetchWeather({ + required double latitude, + required double longitude, + }) async { + try { + final result = await _channel.invokeMethod('fetchWeather', { + 'latitude': latitude, + 'longitude': longitude, + }); + return result ?? false; + } on PlatformException catch (e) { + debugPrint('Native weather fetch failed: ${e.message}'); + return false; + } + } + + // ============ Cache Reading ============ + + /// Get cached weather data if available. + static Future getCachedWeather() async { + final jsonStr = await HomeWidget.getWidgetData(_keyCachedWeather); + if (jsonStr == null) return null; + + try { + final json = jsonDecode(jsonStr) as Map; + return WeatherData.fromJson(json); + } catch (e) { + debugPrint('Error parsing cached weather: $e'); + return null; + } + } + + /// Get cached city name. + static Future getCachedCityName() async { + return HomeWidget.getWidgetData(_keyCachedCityName); + } + + /// Get cached location source. + static Future getCachedLocationSource() async { + return HomeWidget.getWidgetData(_keyCachedLocationSource); + } + + /// Check if cached data is stale (older than 15 minutes). + static Future isCacheStale() async { + final lastUpdate = await HomeWidget.getWidgetData(_keyLastWeatherUpdate); + if (lastUpdate == null) return true; + + final age = DateTime.now().millisecondsSinceEpoch - lastUpdate; + return age > _staleThreshold.inMilliseconds; + } + + // ============ Cache Writing ============ + + /// Save location info to cache for offline display. + static Future cacheLocationInfo(String? cityName, String? locationSource) async { + if (cityName != null) { + await HomeWidget.saveWidgetData(_keyCachedCityName, cityName); + } + if (locationSource != null) { + await HomeWidget.saveWidgetData(_keyCachedLocationSource, locationSource); + } + } + + /// Generate SVG string using native Kotlin generator. + /// Returns null if generation fails (e.g., no cached weather data). + /// + /// The native generator reads weather data from SharedPreferences, + /// so weather must be cached before calling this. + static Future generateSvg({ + required int width, + required int height, + required bool isLight, + required bool usesFahrenheit, + }) async { + try { + final result = await _channel.invokeMethod('generateSvg', { + 'width': width, + 'height': height, + 'isLight': isLight, + 'usesFahrenheit': usesFahrenheit, + }); + return result; + } on PlatformException catch (e) { + debugPrint('Native SVG generation failed: ${e.message}'); + return null; + } + } + + /// Generate both light and dark SVG strings. + /// Returns a record with light and dark SVGs, or nulls if generation fails. + static Future<({String? light, String? dark})> generateSvgPair({ + required int width, + required int height, + required bool usesFahrenheit, + }) async { + final light = await generateSvg( + width: width, + height: height, + isLight: true, + usesFahrenheit: usesFahrenheit, + ); + final dark = await generateSvg( + width: width, + height: height, + isLight: false, + usesFahrenheit: usesFahrenheit, + ); + return (light: light, dark: dark); + } +} diff --git a/lib/services/svg_chart_generator.dart b/lib/services/svg_chart_generator.dart deleted file mode 100644 index b6e84ea..0000000 --- a/lib/services/svg_chart_generator.dart +++ /dev/null @@ -1,490 +0,0 @@ -import 'dart:math' as math; - -import 'package:intl/intl.dart'; - -import '../constants.dart'; -import '../models/weather_data.dart'; - -/// SVG color representation (no dart:ui Color dependency). -/// This allows the generator to work in background isolates without dart:ui. -class SvgColor { - final int r, g, b, a; - const SvgColor(this.r, this.g, this.b, [this.a = 255]); - - /// Create from ARGB int value (e.g., Color.value from Flutter). - factory SvgColor.fromArgb(int argb) { - return SvgColor( - (argb >> 16) & 0xFF, - (argb >> 8) & 0xFF, - argb & 0xFF, - (argb >> 24) & 0xFF, - ); - } - - /// Convert to hex color string (#RRGGBB). - String toHex() => '#${r.toRadixString(16).padLeft(2, '0')}' - '${g.toRadixString(16).padLeft(2, '0')}' - '${b.toRadixString(16).padLeft(2, '0')}'; - - /// Get opacity as 0.0-1.0 value. - double get opacity => a / 255.0; -} - -/// Chart colors for SVG generation. -class SvgChartColors { - final SvgColor temperatureLine; - final SvgColor temperatureGradientStart; - final SvgColor temperatureGradientEnd; - final SvgColor precipitationBar; - final SvgColor daylightBar; - final SvgColor nowIndicator; - final SvgColor timeLabel; - final SvgColor cardBackground; - final SvgColor primaryText; - - const SvgChartColors({ - required this.temperatureLine, - required this.temperatureGradientStart, - required this.temperatureGradientEnd, - required this.precipitationBar, - required this.daylightBar, - required this.nowIndicator, - required this.timeLabel, - required this.cardBackground, - required this.primaryText, - }); - - static const light = SvgChartColors( - temperatureLine: SvgColor(0xFF, 0x6B, 0x6B), - temperatureGradientStart: SvgColor(0xFF, 0x6B, 0x6B, 0x40), - temperatureGradientEnd: SvgColor(0xFF, 0x6B, 0x6B, 0x00), - precipitationBar: SvgColor(0x4E, 0xCD, 0xC4), - daylightBar: SvgColor(0xFF, 0x8F, 0x00), // Dark amber (visible on white) - nowIndicator: SvgColor(0x4A, 0x55, 0x68), - timeLabel: SvgColor(0x4A, 0x55, 0x68), - cardBackground: SvgColor(0xFF, 0xFF, 0xFF), - primaryText: SvgColor(0x2D, 0x34, 0x36), - ); - - static const dark = SvgChartColors( - temperatureLine: SvgColor(0xFF, 0x76, 0x75), - temperatureGradientStart: SvgColor(0xFF, 0x76, 0x75, 0x60), - temperatureGradientEnd: SvgColor(0xFF, 0x76, 0x75, 0x00), - precipitationBar: SvgColor(0x00, 0xCE, 0xC9), - daylightBar: SvgColor(0xFF, 0xFF, 0x00), // Pure yellow - nowIndicator: SvgColor(0xE0, 0xE0, 0xE0), - timeLabel: SvgColor(0xE0, 0xE0, 0xE0), - cardBackground: SvgColor(0x2D, 0x2D, 0x2D), // Neutral gray (matches MeteogramColors.dark) - primaryText: SvgColor(0xFF, 0xFF, 0xFF), - ); - - /// Create colors with custom temperature line and time label colors. - /// Used to apply Material You dynamic colors. - SvgChartColors withDynamicColors({ - required SvgColor temperatureLine, - required SvgColor timeLabel, - }) { - return SvgChartColors( - temperatureLine: temperatureLine, - temperatureGradientStart: SvgColor( - temperatureLine.r, - temperatureLine.g, - temperatureLine.b, - temperatureGradientStart.a, - ), - temperatureGradientEnd: SvgColor( - temperatureLine.r, - temperatureLine.g, - temperatureLine.b, - 0x00, - ), - precipitationBar: precipitationBar, - daylightBar: daylightBar, - nowIndicator: nowIndicator, - timeLabel: timeLabel, - cardBackground: cardBackground, - primaryText: primaryText, - ); - } -} - -/// Generates SVG meteogram charts for background widget updates. -class SvgChartGenerator { - /// Format number as integer for compatibility. - String _n(double v) => v.round().toString(); - - /// Scale factor for font sizes and stroke widths. - late double _scale; - - /// Locale for time formatting. - late String _locale; - - /// Whether to display temperatures in Fahrenheit. - late bool _usesFahrenheit; - - /// Scaled stroke width. - String _strokeWidth(double baseWidth) => (baseWidth * _scale).toStringAsFixed(1); - - String generate({ - required List data, - required int nowIndex, - required double latitude, - required double longitude, - required SvgChartColors colors, - required double width, - required double height, - bool usePastFade = true, - String locale = 'en', - double scale = 1.0, - bool usesFahrenheit = false, - }) { - _locale = locale; - _scale = scale; - _usesFahrenheit = usesFahrenheit; - if (data.isEmpty) { - return ''; - } - - final svg = StringBuffer(); - // Reserve space for time labels based on font size - final timeFontSize = width * ChartConstants.timeFontSizeRatio; - final chartHeight = (height - timeFontSize * 1.5) * ChartConstants.chartHeightRatio; - final nowFraction = (nowIndex + 1) / data.length; - - svg.write(''); - - // Gradient definitions - svg.write(''); - _writeGradientDefs(svg, colors, nowFraction, usePastFade); - svg.write(''); - - // No background - widget uses system background via ?android:attr/colorBackground - - // Chart group with optional past-time fade mask - if (usePastFade) { - svg.write(''); - } else { - svg.write(''); - } - - // Daylight bars (with gradient) - _writeDaylightBars(svg, data, latitude, longitude, colors, width, chartHeight); - - // Precipitation bars (with gradient) - _writePrecipitationBars(svg, data, colors, width, chartHeight); - - // Temperature line (with gradient fill) - _writeTemperatureLine(svg, data, colors, width, chartHeight); - - // Now indicator - final nowX = (nowIndex / (data.length - 1)) * width; - svg.write(''); - - // Grid lines at 12h intervals - for (var i = nowIndex + 12; i < data.length - 8; i += 12) { - final x = (i / (data.length - 1)) * width; - svg.write(''); - } - - svg.write(''); - - // Temperature labels (outside mask for full opacity) - _writeTempLabels(svg, data, colors, width, chartHeight, nowFraction); - - // Time labels - _writeTimeLabels(svg, data, nowIndex, colors, width, height, chartHeight); - - svg.write(''); - return svg.toString(); - } - - /// Write gradient definitions to SVG defs section. - void _writeGradientDefs(StringBuffer svg, SvgChartColors colors, double nowFraction, bool usePastFade) { - // Temperature area gradient (vertical: line color fading to transparent) - svg.write(''); - svg.write(''); - svg.write(''); - svg.write(''); - - // Daylight bar gradient (vertical: fades at top, semi-solid at bottom - matches precipitation style inverted) - svg.write(''); - svg.write(''); - svg.write(''); - svg.write(''); - - // Precipitation bar gradient (vertical: fades at top, semi-solid at bottom - allows daylight to show through) - svg.write(''); - svg.write(''); - svg.write(''); - svg.write(''); - - // Past-time fade mask (horizontal gradient: faded on left, full opacity at now line) - if (usePastFade) { - final fadeStop1 = (nowFraction * 0.75 * 100).round(); - final fadeStop2 = (nowFraction * 100).round(); - svg.write(''); - svg.write(''); - svg.write(''); - svg.write(''); - svg.write(''); - svg.write(''); - svg.write(''); - } - } - - void _writeDaylightBars(StringBuffer svg, List data, - double latitude, double longitude, SvgChartColors colors, double width, double chartHeight) { - final slotWidth = width / data.length; - final barWidth = slotWidth * ChartConstants.barWidthRatio; - - svg.write(''); - for (var i = 0; i < data.length; i++) { - final daylight = _calculateDaylight(data[i], latitude, longitude); - if (daylight <= 0) continue; - - final barHeight = daylight * chartHeight; - final x = i * slotWidth + (slotWidth - barWidth) / 2; - - // Use gradient fill for daylight bars - svg.write(''); - } - svg.write(''); - } - - void _writePrecipitationBars(StringBuffer svg, List data, - SvgChartColors colors, double width, double chartHeight) { - final maxPrecip = data.map((d) => d.precipitation).reduce((a, b) => a > b ? a : b); - if (maxPrecip == 0) return; - - final slotWidth = width / data.length; - final barWidth = slotWidth * ChartConstants.barWidthRatio; - - svg.write(''); - for (var i = 0; i < data.length; i++) { - final precip = data[i].precipitation; - if (precip <= 0) continue; - - final normalized = (precip / 10.0).clamp(0.0, 1.0); - final barHeight = math.sqrt(normalized) * chartHeight; - final x = i * slotWidth + (slotWidth - barWidth) / 2; - - // Use gradient fill for precipitation bars - svg.write(''); - } - svg.write(''); - } - - void _writeTemperatureLine(StringBuffer svg, List data, - SvgChartColors colors, double width, double chartHeight) { - final temps = data.map((d) => d.temperature).toList(); - final minTemp = temps.reduce((a, b) => a < b ? a : b); - final maxTemp = temps.reduce((a, b) => a > b ? a : b); - final tempRange = (maxTemp - minTemp).clamp(1.0, double.infinity); - final yPadding = tempRange * ChartConstants.tempRangePaddingRatio; - - final points = >[]; - for (var i = 0; i < data.length; i++) { - final x = (i / (data.length - 1)) * width; - final normalizedTemp = (data[i].temperature - minTemp + yPadding) / (tempRange + 2 * yPadding); - final y = chartHeight * (1 - normalizedTemp); - points.add([x, y]); - } - - // Build smooth cubic bezier path - final path = StringBuffer('M ${_n(points[0][0])} ${_n(points[0][1])}'); - for (var i = 1; i < points.length; i++) { - final p0 = points[i - 1]; - final p1 = points[i]; - final dx = p1[0] - p0[0]; - final cp1x = p0[0] + dx * 0.35; - final cp2x = p1[0] - dx * 0.35; - path.write(' C ${_n(cp1x)} ${_n(p0[1])} ${_n(cp2x)} ${_n(p1[1])} ${_n(p1[0])} ${_n(p1[1])}'); - } - - // Area fill with gradient - final areaPath = '$path L ${_n(width)} ${_n(chartHeight)} L 0 ${_n(chartHeight)} Z'; - svg.write(''); - - // Temperature line with dark outline for visibility on daylight bars - svg.write(''); - svg.write(''); - } - - /// Format temperature for display, converting to Fahrenheit if needed. - String _formatTemp(double celsius) { - if (_usesFahrenheit) { - return (celsius * 9 / 5 + 32).round().toString(); - } - return celsius.round().toString(); - } - - void _writeTempLabels(StringBuffer svg, List data, - SvgChartColors colors, double width, double chartHeight, double nowFraction) { - final temps = data.map((d) => d.temperature).toList(); - final minTemp = temps.reduce((a, b) => a < b ? a : b); - final maxTemp = temps.reduce((a, b) => a > b ? a : b); - final midTemp = (minTemp + maxTemp) / 2; - final tempRange = (maxTemp - minTemp).clamp(1.0, double.infinity); - final yPadding = tempRange * 0.10; - - // Use same Y calculation as temperature line for alignment - double tempToY(double temp) { - final normalizedTemp = (temp - minTemp + yPadding) / (tempRange + 2 * yPadding); - return chartHeight * (1 - normalizedTemp); - } - - final centerX = (nowFraction / 2.5) * width; - - // Font size relative to width - final fontSize = (width * ChartConstants.tempFontSizeRatio).round(); - final style = 'fill="${colors.temperatureLine.toHex()}" font-size="$fontSize" font-weight="bold" font-family="sans-serif" text-anchor="middle"'; - - // Align labels with actual temperature positions on the line - // Add offset to account for text height - final yOffset = fontSize * 0.4; - svg.write('${_formatTemp(maxTemp)}'); - svg.write('${_formatTemp(midTemp)}'); - svg.write('${_formatTemp(minTemp)}'); - } - - void _writeTimeLabels(StringBuffer svg, List data, int nowIndex, - SvgChartColors colors, double width, double height, double chartHeight) { - // Font size relative to width - final fontSize = (width * ChartConstants.timeFontSizeRatio).round(); - // Position labels 60% down in the area below the chart - final labelY = chartHeight + (height - chartHeight) * 0.6; - final style = 'fill="${colors.timeLabel.toHex()}" font-size="$fontSize" font-weight="600" font-family="sans-serif" text-anchor="middle" dominant-baseline="middle"'; - - for (var i = nowIndex; i < data.length - 8; i++) { - final offset = i - nowIndex; - if (offset < 0 || offset % 12 != 0) continue; - - // Convert UTC to local time for display, use locale-aware formatting - final localTime = data[i].time.toLocal(); - final timeStr = DateFormat.j(_locale).format(localTime); - final x = (i / (data.length - 1)) * width; - - svg.write('$timeStr'); - } - } - - /// Calculate solar elevation angle using simplified solar position algorithm. - /// - /// This determines how high the sun is above the horizon at a given time and location. - /// Uses a simplified approximation suitable for daylight visualization (not precision astronomy). - /// - /// Algorithm: - /// 1. Calculate solar declination (sun's position relative to equator) using day of year - /// 2. Calculate hour angle (sun's position in daily rotation), corrected for longitude - /// 3. Apply spherical trigonometry to get elevation angle - /// - /// Based on NOAA Solar Calculator simplified formulas. - /// Reference: https://www.esrl.noaa.gov/gmd/grad/solcalc/ - /// - /// @param latitude Geographic latitude in degrees (-90 to +90) - /// @param longitude Geographic longitude in degrees (-180 to +180, positive = East) - /// @param time UTC time for calculation - /// @return Solar elevation angle in degrees (negative = below horizon, 0 = horizon, positive = above) - double _solarElevation(double latitude, double longitude, DateTime time) { - final dayOfYear = time.difference(DateTime(time.year, 1, 1)).inDays + 1; - final utcHour = time.toUtc().hour + time.toUtc().minute / 60.0; - - // Solar declination: angle between sun's rays and equatorial plane - // Varies from -23.45° (winter solstice) to +23.45° (summer solstice) - final declination = 23.45 * math.sin(2 * math.pi / 365 * (284 + dayOfYear)); - - // Convert UTC to local solar time using longitude - // Longitude correction: 15° = 1 hour (Earth rotates 360° in 24 hours) - // Positive longitude (East) = sun rises earlier = add to UTC - final solarHour = utcHour + longitude / 15.0; - - // Hour angle: angular distance from solar noon (15° per hour) - final hourAngle = 15.0 * (solarHour - 12); - - // Convert to radians for trigonometry - final latRad = latitude * math.pi / 180; - final decRad = declination * math.pi / 180; - final haRad = hourAngle * math.pi / 180; - - // Spherical trigonometry formula for solar elevation - final sinElevation = math.sin(latRad) * math.sin(decRad) + math.cos(latRad) * math.cos(decRad) * math.cos(haRad); - return math.asin(sinElevation.clamp(-1.0, 1.0)) * 180 / math.pi; - } - - /// Calculate clear-sky illuminance at ground level in lux. - /// - /// Estimates how bright daylight would be with perfectly clear skies (no clouds). - /// Uses atmospheric scattering model to account for sunlight attenuation through atmosphere. - /// - /// Algorithm based on CIE (International Commission on Illumination) clear sky model: - /// - Below -6° elevation: Astronomical twilight, negligible illuminance (0 lux) - /// - At horizon (0°): ~400 lux (civil twilight) - /// - At zenith (90°): ~120,000 lux (full daylight) - /// - /// The formula accounts for: - /// - Atmospheric mass (path length through atmosphere) - /// - Rayleigh scattering (blue sky effect) - /// - Direct and diffuse components of sunlight - /// - /// @param elevation Solar elevation angle in degrees (from _solarElevation) - /// @return Illuminance in lux (0 to ~133,000) - double _clearSkyIlluminance(double elevation) { - if (elevation < -6) return 0; // Below astronomical twilight - - final elevRad = elevation * math.pi / 180; - final u = math.sin(elevRad); - - // Atmospheric mass approximation (relative path length through atmosphere) - const x = 753.66156; // Empirical constant for atmospheric model - final s = math.asin((x * math.cos(elevRad) / (x + 1)).clamp(-1.0, 1.0)); - final m = x * (math.cos(s) - u) + math.cos(s); - - // Atmospheric extinction and scattering - final factor = math.exp(-0.2 * m) * u + 0.0289 * math.exp(-0.042 * m) * (1 + (elevation + 90) * u / 57.29577951); - - // Scale to typical clear-sky maximum (~133,775 lux) - return 133775 * factor.clamp(0.0, double.infinity); - } - - /// Calculate effective daylight intensity (0.0 to 1.0) for display as bar height. - /// - /// Combines solar position, cloud cover, and precipitation to estimate - /// how bright it actually feels outside at a given time. - /// - /// Algorithm: - /// 1. Calculate solar position → potential illuminance (0-133k lux) - /// 2. Normalize to 0-1 range (using 130k lux as typical max daylight) - /// 3. Attenuate by cloud cover: - /// - 0% clouds: divisor = 1 (no reduction) - /// - 50% clouds: divisor = 3.16 (68% reduction) - /// - 100% clouds: divisor = 10 (90% reduction) - /// 4. Attenuate by precipitation (rain/snow reduces perceived brightness) - /// 5. Apply sqrt for perceptual scaling (human brightness perception is non-linear) - /// - /// Result is normalized 0-1 value where: - /// - 0.0 = Night or heavily overcast - /// - 0.5 = Partly cloudy day - /// - 1.0 = Clear sunny day at solar noon - /// - /// @param data Hourly weather data (cloud cover, precipitation) - /// @param latitude Geographic latitude for solar position calculation - /// @param longitude Geographic longitude for solar time correction - /// @return Normalized daylight intensity (0.0 to 1.0) for bar visualization - double _calculateDaylight(HourlyData data, double latitude, double longitude) { - final elevation = _solarElevation(latitude, longitude, data.time); - final clearSkyLux = _clearSkyIlluminance(elevation); - if (clearSkyLux <= 0) return 0; // Sun below horizon - - // Normalize to 0-1 range (130k lux = typical bright day) - final potential = (clearSkyLux / 130000.0).clamp(0.0, 1.0); - - // Attenuate by cloud cover (exponential: 100% clouds = 90% reduction) - final cloudDivisor = math.pow(10, data.cloudCover / 100.0); - - // Attenuate by precipitation (rain/snow darkens perception) - final precipDivisor = 1 + 0.5 * math.pow(data.precipitation, 0.6); - - // Apply sqrt for perceptual brightness (Weber-Fechner law approximation) - return math.sqrt(potential / cloudDivisor / precipDivisor); - } -} diff --git a/lib/services/units_service.dart b/lib/services/units_service.dart index 5e3c52f..f9d31ea 100644 --- a/lib/services/units_service.dart +++ b/lib/services/units_service.dart @@ -5,19 +5,11 @@ class UnitsService { /// Countries that use Fahrenheit. static const _fahrenheitCountries = {'US', 'LR', 'MM'}; - /// Countries that use inches for precipitation. - static const _inchesCountries = {'US', 'GB'}; - /// Check if the locale uses Fahrenheit. static bool usesFahrenheit(Locale locale) { return _fahrenheitCountries.contains(locale.countryCode); } - /// Check if the locale uses inches for precipitation. - static bool usesInches(Locale locale) { - return _inchesCountries.contains(locale.countryCode); - } - /// Format temperature for display. static String formatTemperature(double celsius, Locale locale) { return formatTemperatureFromBool(celsius, usesFahrenheit(locale)); @@ -32,48 +24,4 @@ class UnitsService { } return '${celsius.round()}°C'; } - - /// Format temperature value only (without unit). - static String formatTemperatureValue(double celsius, Locale locale) { - if (usesFahrenheit(locale)) { - final fahrenheit = celsius * 9 / 5 + 32; - return fahrenheit.round().toString(); - } - return celsius.round().toString(); - } - - /// Get temperature unit string. - static String getTemperatureUnit(Locale locale) { - return usesFahrenheit(locale) ? '°F' : '°C'; - } - - /// Format precipitation for display. - static String formatPrecipitation(double mm, Locale locale) { - if (usesInches(locale)) { - final inches = mm / 25.4; - return '${inches.toStringAsFixed(2)}"'; - } - return '${mm.toStringAsFixed(1)} mm'; - } - - /// Get precipitation unit string. - static String getPrecipitationUnit(Locale locale) { - return usesInches(locale) ? 'in' : 'mm'; - } - - /// Convert temperature to locale unit. - static double convertTemperature(double celsius, Locale locale) { - if (usesFahrenheit(locale)) { - return celsius * 9 / 5 + 32; - } - return celsius; - } - - /// Convert precipitation to locale unit. - static double convertPrecipitation(double mm, Locale locale) { - if (usesInches(locale)) { - return mm / 25.4; - } - return mm; - } } diff --git a/lib/services/weather_service.dart b/lib/services/weather_service.dart deleted file mode 100644 index e8d4a58..0000000 --- a/lib/services/weather_service.dart +++ /dev/null @@ -1,210 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'package:flutter/foundation.dart'; -import 'package:home_widget/home_widget.dart'; -import 'package:http/http.dart' as http; -import '../models/weather_data.dart'; - -/// Service for fetching weather data from Open-Meteo API. -/// Includes caching and retry with Fibonacci backoff. -class WeatherService { - static const String _baseUrl = 'https://api.open-meteo.com/v1/forecast'; - static const String _cacheLocationKey = 'cached_weather_location'; - static const String _cacheCityNameKey = 'cached_city_name'; - static const String _cacheLocationSourceKey = 'cached_location_source'; - - /// Fibonacci backoff delays in minutes: 1, 2, 3, 5, 8 - static const List _retryDelaysMinutes = [1, 2, 3, 5, 8]; - - /// HTTP client for making requests. Defaults to standard client. - /// Can be overridden for testing. - final http.Client _client; - - /// Create a WeatherService with optional custom HTTP client. - WeatherService({http.Client? client}) : _client = client ?? http.Client(); - - /// Fetch weather data for foreground use. - /// Tries once, falls back to cache on failure. Does not block with retries. - Future fetchWeather(double latitude, double longitude) async { - final locationKey = _locationKey(latitude, longitude); - - try { - final data = await _fetchFromApi(latitude, longitude); - await _cacheData(data, locationKey); - return data; - } catch (e) { - // On failure, try to return cached data - final cached = await getCachedWeather(locationKey); - if (cached != null) { - return cached; - } - rethrow; - } - } - - /// Fetch weather data for background use. - /// Retries with Fibonacci backoff (1, 2, 3, 5 minutes) before giving up. - Future fetchWeatherWithRetry(double latitude, double longitude) async { - final locationKey = _locationKey(latitude, longitude); - - try { - final data = await _fetchWithRetry(latitude, longitude); - await _cacheData(data, locationKey); - return data; - } catch (e) { - // On failure after all retries, try to return cached data - final cached = await getCachedWeather(locationKey); - if (cached != null) { - return cached; - } - rethrow; - } - } - - String _locationKey(double latitude, double longitude) { - return '${latitude.toStringAsFixed(2)},${longitude.toStringAsFixed(2)}'; - } - - /// Fetch with Fibonacci backoff retry (1, 2, 3, 5 minutes). - Future _fetchWithRetry(double latitude, double longitude) async { - Exception? lastException; - - // First attempt (no delay) - try { - return await _fetchFromApi(latitude, longitude); - } catch (e) { - lastException = e is Exception ? e : Exception(e.toString()); - } - - // Retry attempts with Fibonacci delays - for (final delayMinutes in _retryDelaysMinutes) { - await Future.delayed(Duration(minutes: delayMinutes)); - - try { - return await _fetchFromApi(latitude, longitude); - } catch (e) { - lastException = e is Exception ? e : Exception(e.toString()); - } - } - - throw lastException ?? WeatherException('Failed after all retries'); - } - - /// Direct API fetch without retry logic. - Future _fetchFromApi(double latitude, double longitude) async { - final uri = Uri.parse(_baseUrl).replace(queryParameters: { - 'latitude': latitude.toString(), - 'longitude': longitude.toString(), - 'hourly': 'temperature_2m,precipitation,cloud_cover', - 'timezone': 'UTC', - 'past_hours': kPastHours.toString(), - 'forecast_days': '2', - }); - - try { - final response = await _client.get(uri).timeout(const Duration(seconds: 5)); - - if (response.statusCode == 200) { - final json = jsonDecode(response.body) as Map; - return WeatherData.fromJson(json); - } else if (response.statusCode == 429) { - throw WeatherException('Rate limited. Please try again later.'); - } else { - // Log detailed error for debugging, show generic message to user - debugPrint('Weather API error: ${response.statusCode} ${response.body}'); - throw WeatherException('Failed to load weather data. Please try again later.'); - } - } on SocketException { - throw WeatherException('No internet connection'); - } on TimeoutException { - throw WeatherException('Connection timed out'); - } - } - - /// Cache weather data to HomeWidget shared preferences. - /// Uses the same keys as background_service.dart for unified cache. - Future _cacheData(WeatherData data, String locationKey) async { - // Save to HomeWidget (shared with background service) - await HomeWidget.saveWidgetData('cached_weather', jsonEncode(data.toJson())); - await HomeWidget.saveWidgetData('cached_latitude', data.latitude); - await HomeWidget.saveWidgetData('cached_longitude', data.longitude); - await HomeWidget.saveWidgetData('last_weather_update', DateTime.now().millisecondsSinceEpoch); - - // Also save location key for location-specific cache validation - await HomeWidget.saveWidgetData(_cacheLocationKey, locationKey); - } - - /// Cache location info separately (called from UI after successful load). - /// Uses HomeWidget for background service access. - Future cacheLocationInfo(String? cityName, String? locationSource) async { - if (cityName != null) { - await HomeWidget.saveWidgetData(_cacheCityNameKey, cityName); - } - if (locationSource != null) { - await HomeWidget.saveWidgetData(_cacheLocationSourceKey, locationSource); - } - } - - /// Get cached city name. - Future getCachedCityName() async { - return HomeWidget.getWidgetData(_cacheCityNameKey); - } - - /// Get cached location source. - Future getCachedLocationSource() async { - return HomeWidget.getWidgetData(_cacheLocationSourceKey); - } - - /// Get cached weather data if available and for the same location. - Future getCachedWeather([String? locationKey]) async { - final cachedJson = await HomeWidget.getWidgetData('cached_weather'); - if (cachedJson == null) return null; - - // If location key provided, check it matches - if (locationKey != null) { - final cachedLocation = await HomeWidget.getWidgetData(_cacheLocationKey); - if (cachedLocation != locationKey) { - return null; - } - } - - try { - final json = jsonDecode(cachedJson) as Map; - return WeatherData.fromJson(json); - } catch (e) { - return null; - } - } - - /// Check if cached data is stale (older than maxAge). - Future isCacheStale({Duration maxAge = const Duration(hours: 1)}) async { - final cached = await getCachedWeather(); - if (cached == null) return true; - return DateTime.now().difference(cached.fetchedAt) > maxAge; - } - - /// Clear cached weather data. - Future clearCache() async { - // Clear from HomeWidget - await HomeWidget.saveWidgetData('cached_weather', null); - await HomeWidget.saveWidgetData('cached_latitude', null); - await HomeWidget.saveWidgetData('cached_longitude', null); - await HomeWidget.saveWidgetData('last_weather_update', null); - await HomeWidget.saveWidgetData(_cacheLocationKey, null); - } - - /// Dispose of resources (close HTTP client). - void dispose() { - _client.close(); - } -} - -/// Exception thrown when weather data cannot be fetched. -class WeatherException implements Exception { - final String message; - WeatherException(this.message); - - @override - String toString() => message; -} diff --git a/lib/services/widget_service.dart b/lib/services/widget_service.dart index 7b088e4..6528988 100644 --- a/lib/services/widget_service.dart +++ b/lib/services/widget_service.dart @@ -1,220 +1,43 @@ -import 'dart:io'; -import 'dart:ui'; import 'package:flutter/foundation.dart'; import 'package:home_widget/home_widget.dart'; -import 'package:path_provider/path_provider.dart'; -import '../models/weather_data.dart'; -import 'svg_chart_generator.dart'; -import 'units_service.dart'; - -/// SVG chart file names used by both Flutter and native widget code. -const String kLightSvgFileName = 'meteogram_light.svg'; -const String kDarkSvgFileName = 'meteogram_dark.svg'; - -/// Default fallback dimensions - must match WidgetUtils.kt and background_service.dart -const int kDefaultWidthPx = 1000; -const int kDefaultHeightPx = 500; - -/// Widget dimensions in pixels as reported by the native widget provider. -class WidgetDimensions { - final int widthPx; - final int heightPx; - final double density; - - const WidgetDimensions({ - required this.widthPx, - required this.heightPx, - required this.density, - }); - - /// Logical size for Flutter rendering. - Size get logicalSize => Size(widthPx / density, heightPx / density); - - @override - String toString() => 'WidgetDimensions(${widthPx}x${heightPx}px, density: $density)'; -} /// Service for updating the home screen widget. +/// +/// Note: SVG generation and weather fetching are handled natively in Kotlin. +/// This service only handles widget metadata updates and triggering refreshes. class WidgetService { static const _androidWidgetName = 'MeteogramWidgetProvider'; static const _iosWidgetName = 'MeteogramWidget'; - /// Update the home screen widget with new weather data. - Future updateWidget({ - required WeatherData weatherData, - required String? locationName, - required Locale locale, - }) async { + /// Trigger a native widget update. + /// The native code will generate SVGs from cached weather data. + Future triggerWidgetUpdate() async { try { - final currentHour = weatherData.getCurrentHour(); - - // Save current temperature using locale-aware formatting - final tempString = currentHour != null - ? UnitsService.formatTemperature(currentHour.temperature, locale) - : '--°'; - await HomeWidget.saveWidgetData('current_temperature', tempString); - - // Save location name - await HomeWidget.saveWidgetData('location_name', locationName ?? ''); - - // Trigger widget update (SVG chart paths are saved by generateAndSaveSvgCharts) await HomeWidget.updateWidget( androidName: _androidWidgetName, iOSName: _iosWidgetName, ); + debugPrint('Triggered native widget update'); } catch (e) { - debugPrint('Error updating widget: $e'); + debugPrint('Error triggering widget update: $e'); } } - /// Generate and save SVG charts for the widget. - /// This runs in the main isolate and complements PNG generation. - /// - /// Optional [lightColors] and [darkColors] can be provided to apply - /// Material You dynamic colors. If not provided, uses default colors. - /// Returns true if charts were generated successfully, false on error. - Future generateAndSaveSvgCharts({ - required List displayData, - required int nowIndex, - required double latitude, - required double longitude, - String locale = 'en', - bool usesFahrenheit = false, - SvgChartColors? lightColors, - SvgChartColors? darkColors, - }) async { + /// Save current temperature for widget display. + Future saveCurrentTemperature(String tempString) async { try { - final generator = SvgChartGenerator(); - final docsDir = await getApplicationDocumentsDirectory(); - final effectiveLightColors = lightColors ?? SvgChartColors.light; - final effectiveDarkColors = darkColors ?? SvgChartColors.dark; - - // Get list of widget IDs (stored as comma-separated string by native code) - final widgetIdsStr = await HomeWidget.getWidgetData('widget_ids'); - - if (widgetIdsStr != null && widgetIdsStr.isNotEmpty) { - // Generate per-widget SVGs at each widget's dimensions - final widgetIds = widgetIdsStr.split(',').map((s) => int.tryParse(s.trim())).whereType().toList(); - debugPrint('Generating SVGs for ${widgetIds.length} widgets: $widgetIds'); - - for (final widgetId in widgetIds) { - final widthPx = await HomeWidget.getWidgetData('widget_${widgetId}_width_px') ?? kDefaultWidthPx; - final heightPx = await HomeWidget.getWidgetData('widget_${widgetId}_height_px') ?? kDefaultHeightPx; - - await _generateAndSaveSvgPair( - generator: generator, - docsDir: docsDir, - displayData: displayData, - nowIndex: nowIndex, - latitude: latitude, - longitude: longitude, - widthPx: widthPx, - heightPx: heightPx, - locale: locale, - usesFahrenheit: usesFahrenheit, - lightColors: effectiveLightColors, - darkColors: effectiveDarkColors, - widgetId: widgetId, - ); - } - } else { - // No widget IDs tracked yet, generate generic SVG for backward compatibility - final dimensions = await getWidgetDimensions(); - final widthPx = dimensions?.widthPx ?? kDefaultWidthPx; - final heightPx = dimensions?.heightPx ?? kDefaultHeightPx; - - await _generateAndSaveSvgPair( - generator: generator, - docsDir: docsDir, - displayData: displayData, - nowIndex: nowIndex, - latitude: latitude, - longitude: longitude, - widthPx: widthPx, - heightPx: heightPx, - locale: locale, - usesFahrenheit: usesFahrenheit, - lightColors: effectiveLightColors, - darkColors: effectiveDarkColors, - widgetId: null, - ); - } - - // Update last render time for conditional re-render on unlock - await HomeWidget.saveWidgetData('last_render_time', DateTime.now().millisecondsSinceEpoch); - - return true; + await HomeWidget.saveWidgetData('current_temperature', tempString); } catch (e) { - debugPrint('Error generating SVG charts: $e'); - return false; + debugPrint('Error saving temperature: $e'); } } - /// Helper to generate and save a pair of light/dark SVG files. - Future _generateAndSaveSvgPair({ - required SvgChartGenerator generator, - required Directory docsDir, - required List displayData, - required int nowIndex, - required double latitude, - required double longitude, - required int widthPx, - required int heightPx, - required String locale, - required bool usesFahrenheit, - required SvgChartColors lightColors, - required SvgChartColors darkColors, - required int? widgetId, - }) async { - final svgLight = generator.generate( - data: displayData, - nowIndex: nowIndex, - latitude: latitude, - longitude: longitude, - colors: lightColors, - width: widthPx.toDouble(), - height: heightPx.toDouble(), - locale: locale, - usesFahrenheit: usesFahrenheit, - ); - - final svgDark = generator.generate( - data: displayData, - nowIndex: nowIndex, - latitude: latitude, - longitude: longitude, - colors: darkColors, - width: widthPx.toDouble(), - height: heightPx.toDouble(), - locale: locale, - usesFahrenheit: usesFahrenheit, - ); - - // Use widget-specific file names if widgetId provided - final lightFileName = widgetId != null ? 'meteogram_light_$widgetId.svg' : kLightSvgFileName; - final darkFileName = widgetId != null ? 'meteogram_dark_$widgetId.svg' : kDarkSvgFileName; - final lightPath = '${docsDir.path}/$lightFileName'; - final darkPath = '${docsDir.path}/$darkFileName'; - final lightTempPath = '$lightPath.tmp'; - final darkTempPath = '$darkPath.tmp'; - - // Write to temp files first - await File(lightTempPath).writeAsString(svgLight); - await File(darkTempPath).writeAsString(svgDark); - - // Atomic rename to final paths - await File(lightTempPath).rename(lightPath); - await File(darkTempPath).rename(darkPath); - - // Save paths for native widget - if (widgetId != null) { - await HomeWidget.saveWidgetData('svg_path_light_$widgetId', lightPath); - await HomeWidget.saveWidgetData('svg_path_dark_$widgetId', darkPath); - debugPrint('SVG charts generated for widget $widgetId: $lightPath'); - } else { - await HomeWidget.saveWidgetData('svg_path_light', lightPath); - await HomeWidget.saveWidgetData('svg_path_dark', darkPath); - debugPrint('SVG charts generated: $lightPath, $darkPath'); + /// Save location name for widget display. + Future saveLocationName(String? locationName) async { + try { + await HomeWidget.saveWidgetData('location_name', locationName ?? ''); + } catch (e) { + debugPrint('Error saving location: $e'); } } @@ -222,38 +45,6 @@ class WidgetService { static Future initialize() async { // Set app group ID for iOS await HomeWidget.setAppGroupId('group.org.bortnik.meteogram'); - - // Clean up any orphaned .tmp files from previous crashes - try { - final docsDir = await getApplicationDocumentsDirectory(); - final tmpFiles = [ - File('${docsDir.path}/$kLightSvgFileName.tmp'), - File('${docsDir.path}/$kDarkSvgFileName.tmp'), - ]; - for (final file in tmpFiles) { - try { - await file.delete(); - debugPrint('Cleaned up orphaned temp file: ${file.path}'); - } catch (_) { - // File doesn't exist - nothing to clean up - } - } - } catch (e) { - debugPrint('Error cleaning up temp files: $e'); - } - } - - /// Trigger a native widget update (to check theme mismatch and show indicator). - Future triggerWidgetUpdate() async { - try { - await HomeWidget.updateWidget( - androidName: _androidWidgetName, - iOSName: _iosWidgetName, - ); - debugPrint('Triggered widget update for theme check'); - } catch (e) { - debugPrint('Error triggering widget update: $e'); - } } /// Check if widget was resized and clear the flag. @@ -263,7 +54,7 @@ class WidgetService { final resized = await HomeWidget.getWidgetData('widget_resized'); if (resized == true) { await HomeWidget.saveWidgetData('widget_resized', false); - debugPrint('Widget was resized, triggering re-render'); + debugPrint('Widget was resized'); return true; } return false; @@ -272,35 +63,4 @@ class WidgetService { return false; } } - - /// Get widget dimensions from native widget provider. - /// Returns null if dimensions haven't been set (widget not yet placed). - Future getWidgetDimensions() async { - try { - final widthPx = await HomeWidget.getWidgetData('widget_width_px'); - final heightPx = await HomeWidget.getWidgetData('widget_height_px'); - final density = await HomeWidget.getWidgetData('widget_density'); - - if (widthPx == null || heightPx == null || density == null) { - debugPrint('Widget dimensions not available yet'); - return null; - } - - if (widthPx <= 0 || heightPx <= 0) { - debugPrint('Invalid widget dimensions: ${widthPx}x$heightPx'); - return null; - } - - final dimensions = WidgetDimensions( - widthPx: widthPx, - heightPx: heightPx, - density: density, - ); - debugPrint('Widget dimensions: $dimensions'); - return dimensions; - } catch (e) { - debugPrint('Error getting widget dimensions: $e'); - return null; - } - } } diff --git a/lib/utils/locale_utils.dart b/lib/utils/locale_utils.dart deleted file mode 100644 index cf100f5..0000000 --- a/lib/utils/locale_utils.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'dart:io'; -import 'package:flutter/material.dart'; - -/// Utilities for locale parsing and handling. -class LocaleUtils { - /// Parse a locale string (e.g., "en_US", "en-US", "en") into a Locale. - /// Returns Locale('en') if the input is empty or invalid. - static Locale parseLocaleString(String localeStr) { - if (localeStr.isEmpty) return const Locale('en'); - - // Handle formats: "en", "en_US", "en-US", "en_US.UTF-8" - final cleaned = localeStr.split('.').first; // Remove .UTF-8 suffix - final parts = cleaned.split(RegExp(r'[_-]')).where((p) => p.isNotEmpty).toList(); - - if (parts.isEmpty || parts[0].isEmpty) return const Locale('en'); - - if (parts.length >= 2) { - return Locale(parts[0], parts[1].toUpperCase()); - } - return Locale(parts[0]); - } - - /// Get current system locale from Platform.localeName. - /// Returns Locale with language and country code (e.g., en_US -> Locale('en', 'US')) - static Locale getSystemLocale() { - final localeName = Platform.localeName; - - // Parse locale string (formats: "en", "en_US", "en-US", "en_US.UTF-8") - final cleaned = localeName.split('.').first; // Remove .UTF-8 suffix - final parts = cleaned.split(RegExp(r'[_-]')).where((p) => p.isNotEmpty).toList(); - - if (parts.isEmpty) { - return const Locale('en'); - } - - if (parts.length >= 2) { - return Locale(parts[0], parts[1].toUpperCase()); - } - return Locale(parts[0]); - } -} diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 0b3b35f..ca0b3f4 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,14 +7,12 @@ import Foundation import geolocator_apple import package_info_plus -import path_provider_foundation import shared_preferences_foundation import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) - PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 45b94a4..cb25690 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -41,6 +41,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: ae0db647e668cbb295a3527f0938e4039e004c80099dce2f964102373f5ce0b5 + url: "https://pub.dev" + source: hosted + version: "0.19.10" collection: dependency: transitive description: @@ -193,10 +201,10 @@ packages: dependency: transitive description: name: geolocator_linux - sha256: c4e966f0a7a87e70049eac7a2617f9e16fd4c585a26e4330bdfc3a71e6a721f3 + sha256: d64112a205931926f4363bb6bd48f14cb38e7326833041d170615586cd143797 url: "https://pub.dev" source: hosted - version: "0.2.3" + version: "0.2.4" geolocator_platform_interface: dependency: transitive description: @@ -221,6 +229,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.5" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" gsettings: dependency: transitive description: @@ -233,10 +249,18 @@ packages: dependency: "direct main" description: name: home_widget - sha256: ad9634ef5894f3bac73f04d59e2e5151a39798f49985399fd928dadc828d974a + sha256: d794a73894012459a4c63b94a6dc2cb3ccaa6eb08fb15b974aa7ac642594aed5 + url: "https://pub.dev" + source: hosted + version: "0.9.0" + hooks: + dependency: transitive + description: + name: hooks + sha256: "5410b9f4f6c9f01e8ff0eb81c9801ea13a3c3d39f8f0b1613cda08e27eab3c18" url: "https://pub.dev" source: hosted - version: "0.8.0" + version: "0.20.5" http: dependency: "direct main" description: @@ -293,6 +317,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" matcher: dependency: transitive description: @@ -325,14 +357,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: f8872ea6c7a50ce08db9ae280ca2b8efdd973157ce462826c82f3c3051d154ce + url: "https://pub.dev" + source: hosted + version: "0.17.2" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "55eb67ede1002d9771b3f9264d2c9d30bc364f0267bc1c6cc0883280d5f0c7cb" + url: "https://pub.dev" + source: hosted + version: "9.2.2" package_info_plus: dependency: transitive description: name: package_info_plus - sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968" + sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d url: "https://pub.dev" source: hosted - version: "8.3.1" + version: "9.0.0" package_info_plus_platform_interface: dependency: transitive description: @@ -350,7 +398,7 @@ packages: source: hosted version: "1.9.1" path_provider: - dependency: "direct main" + dependency: transitive description: name: path_provider sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" @@ -369,10 +417,10 @@ packages: dependency: transitive description: name: path_provider_foundation - sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4" + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" url: "https://pub.dev" source: hosted - version: "2.5.1" + version: "2.6.0" path_provider_linux: dependency: transitive description: @@ -421,6 +469,14 @@ packages: 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" shared_preferences: dependency: "direct main" description: @@ -590,10 +646,10 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2" url_launcher_windows: dependency: transitive description: @@ -658,6 +714,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.6.1" + 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.35.0" + flutter: ">=3.38.4" diff --git a/pubspec.yaml b/pubspec.yaml index d9bbd3c..4498101 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -36,17 +36,13 @@ dependencies: # API & Data http: ^1.2.0 shared_preferences: ^2.3.0 - path_provider: ^2.1.0 # Location geolocator: ^14.0.2 geocoding: ^4.0.0 - # Home screen widget - # Pinned to 0.8.0 - last version with JobIntentService (immediate execution) - # Version 0.8.1+ uses WorkManager which delays background callbacks - # See docs/HOME_WIDGET_VERSION_ISSUE.md - home_widget: 0.8.0 + # Home screen widget (SharedPreferences bridge + widget trigger) + home_widget: ^0.9.0 # Internationalization intl: ^0.20.2 diff --git a/test/background_service_test.dart b/test/background_service_test.dart deleted file mode 100644 index 3c1a530..0000000 --- a/test/background_service_test.dart +++ /dev/null @@ -1,221 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:flutter/widgets.dart'; -import 'package:meteogram_widget/services/units_service.dart'; - -// Test helpers that mirror the logic in background_service.dart -// (The actual functions are private, so we test the logic patterns) - -/// Parse locale from URI query param (mirrors _reRenderCharts logic) -Locale? parseLocaleFromUri(String? uriLocale) { - if (uriLocale == null || uriLocale.isEmpty) return null; - - final parts = uriLocale.split('_'); - return parts.length >= 2 - ? Locale(parts[0], parts[1].toUpperCase()) - : Locale(parts[0]); -} - -/// Parse dimensions from URI (mirrors _reRenderCharts logic) -({int width, int height}) parseDimensionsFromUri(String? widthStr, String? heightStr) { - var width = int.tryParse(widthStr ?? '') ?? 0; - var height = int.tryParse(heightStr ?? '') ?? 0; - - // Apply fallback for invalid dimensions - if (width <= 0) width = 1000; - if (height <= 0) height = 500; - - return (width: width, height: height); -} - -/// Parse system locale string (mirrors _getSystemLocale logic) -Locale parseSystemLocale(String localeName) { - // Parse locale string (formats: "en", "en_US", "en-US", "en_US.UTF-8") - final cleaned = localeName.split('.').first; // Remove .UTF-8 suffix - final parts = cleaned.split(RegExp(r'[_-]')); - - if (parts.length >= 2) { - return Locale(parts[0], parts[1].toUpperCase()); - } - return Locale(parts[0]); -} - -void main() { - group('URI locale parsing', () { - test('parses en_US correctly', () { - final locale = parseLocaleFromUri('en_US'); - expect(locale?.languageCode, 'en'); - expect(locale?.countryCode, 'US'); - }); - - test('parses uk_UA correctly', () { - final locale = parseLocaleFromUri('uk_UA'); - expect(locale?.languageCode, 'uk'); - expect(locale?.countryCode, 'UA'); - }); - - test('parses de_DE correctly', () { - final locale = parseLocaleFromUri('de_DE'); - expect(locale?.languageCode, 'de'); - expect(locale?.countryCode, 'DE'); - }); - - test('handles lowercase country code', () { - final locale = parseLocaleFromUri('en_us'); - expect(locale?.languageCode, 'en'); - expect(locale?.countryCode, 'US'); // Should be uppercased - }); - - test('handles language-only locale', () { - final locale = parseLocaleFromUri('en'); - expect(locale?.languageCode, 'en'); - expect(locale?.countryCode, isNull); - }); - - test('returns null for empty string', () { - final locale = parseLocaleFromUri(''); - expect(locale, isNull); - }); - - test('returns null for null', () { - final locale = parseLocaleFromUri(null); - expect(locale, isNull); - }); - }); - - group('URI dimension parsing', () { - test('parses valid dimensions', () { - final dims = parseDimensionsFromUri('1319', '774'); - expect(dims.width, 1319); - expect(dims.height, 774); - }); - - test('uses fallback for zero width', () { - final dims = parseDimensionsFromUri('0', '774'); - expect(dims.width, 1000); - expect(dims.height, 774); - }); - - test('uses fallback for zero height', () { - final dims = parseDimensionsFromUri('1319', '0'); - expect(dims.width, 1319); - expect(dims.height, 500); - }); - - test('uses fallback for both zero', () { - final dims = parseDimensionsFromUri('0', '0'); - expect(dims.width, 1000); - expect(dims.height, 500); - }); - - test('uses fallback for null values', () { - final dims = parseDimensionsFromUri(null, null); - expect(dims.width, 1000); - expect(dims.height, 500); - }); - - test('uses fallback for invalid strings', () { - final dims = parseDimensionsFromUri('abc', 'xyz'); - expect(dims.width, 1000); - expect(dims.height, 500); - }); - - test('uses fallback for negative values', () { - final dims = parseDimensionsFromUri('-100', '-50'); - expect(dims.width, 1000); - expect(dims.height, 500); - }); - }); - - group('System locale parsing', () { - test('parses en_US', () { - final locale = parseSystemLocale('en_US'); - expect(locale.languageCode, 'en'); - expect(locale.countryCode, 'US'); - }); - - test('parses en-US (hyphen separator)', () { - final locale = parseSystemLocale('en-US'); - expect(locale.languageCode, 'en'); - expect(locale.countryCode, 'US'); - }); - - test('parses en_US.UTF-8 (with encoding suffix)', () { - final locale = parseSystemLocale('en_US.UTF-8'); - expect(locale.languageCode, 'en'); - expect(locale.countryCode, 'US'); - }); - - test('parses uk_UA.UTF-8', () { - final locale = parseSystemLocale('uk_UA.UTF-8'); - expect(locale.languageCode, 'uk'); - expect(locale.countryCode, 'UA'); - }); - - test('parses language-only', () { - final locale = parseSystemLocale('en'); - expect(locale.languageCode, 'en'); - expect(locale.countryCode, isNull); - }); - - test('handles lowercase and uppercases country', () { - final locale = parseSystemLocale('en_us'); - expect(locale.countryCode, 'US'); - }); - }); - - group('Locale to temperature unit', () { - test('US uses Fahrenheit', () { - const locale = Locale('en', 'US'); - expect(UnitsService.usesFahrenheit(locale), isTrue); - }); - - test('UK uses Celsius', () { - const locale = Locale('en', 'GB'); - expect(UnitsService.usesFahrenheit(locale), isFalse); - }); - - test('Germany uses Celsius', () { - const locale = Locale('de', 'DE'); - expect(UnitsService.usesFahrenheit(locale), isFalse); - }); - - test('Ukraine uses Celsius', () { - const locale = Locale('uk', 'UA'); - expect(UnitsService.usesFahrenheit(locale), isFalse); - }); - - test('Liberia uses Fahrenheit', () { - const locale = Locale('en', 'LR'); - expect(UnitsService.usesFahrenheit(locale), isTrue); - }); - - test('Myanmar uses Fahrenheit', () { - const locale = Locale('my', 'MM'); - expect(UnitsService.usesFahrenheit(locale), isTrue); - }); - - test('Language-only locale defaults to Celsius', () { - const locale = Locale('en'); - expect(UnitsService.usesFahrenheit(locale), isFalse); - }); - }); - - group('Temperature formatting', () { - test('formats Celsius correctly', () { - expect(UnitsService.formatTemperatureFromBool(20.0, false), '20°C'); - expect(UnitsService.formatTemperatureFromBool(-5.0, false), '-5°C'); - expect(UnitsService.formatTemperatureFromBool(0.0, false), '0°C'); - }); - - test('formats Fahrenheit correctly', () { - expect(UnitsService.formatTemperatureFromBool(0.0, true), '32°F'); - expect(UnitsService.formatTemperatureFromBool(100.0, true), '212°F'); - expect(UnitsService.formatTemperatureFromBool(-17.78, true), '0°F'); - }); - - test('rounds to nearest integer', () { - expect(UnitsService.formatTemperatureFromBool(20.4, false), '20°C'); - expect(UnitsService.formatTemperatureFromBool(20.6, false), '21°C'); - }); - }); -} diff --git a/test/locale_utils_test.dart b/test/locale_utils_test.dart deleted file mode 100644 index 282bdaf..0000000 --- a/test/locale_utils_test.dart +++ /dev/null @@ -1,103 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:meteogram_widget/utils/locale_utils.dart'; - -void main() { - group('LocaleUtils.parseLocaleString', () { - test('parses simple language code', () { - expect(LocaleUtils.parseLocaleString('en'), const Locale('en')); - expect(LocaleUtils.parseLocaleString('de'), const Locale('de')); - expect(LocaleUtils.parseLocaleString('uk'), const Locale('uk')); - }); - - test('parses language_COUNTRY format', () { - final locale = LocaleUtils.parseLocaleString('en_US'); - expect(locale.languageCode, 'en'); - expect(locale.countryCode, 'US'); - }); - - test('parses language-COUNTRY format', () { - final locale = LocaleUtils.parseLocaleString('en-US'); - expect(locale.languageCode, 'en'); - expect(locale.countryCode, 'US'); - }); - - test('parses language_COUNTRY.UTF-8 format', () { - final locale = LocaleUtils.parseLocaleString('en_US.UTF-8'); - expect(locale.languageCode, 'en'); - expect(locale.countryCode, 'US'); - }); - - test('parses language-COUNTRY.UTF-8 format', () { - final locale = LocaleUtils.parseLocaleString('uk-UA.UTF-8'); - expect(locale.languageCode, 'uk'); - expect(locale.countryCode, 'UA'); - }); - - test('handles lowercase country code', () { - final locale = LocaleUtils.parseLocaleString('en_us'); - expect(locale.languageCode, 'en'); - expect(locale.countryCode, 'US'); // Should be uppercase - }); - - test('handles empty string', () { - expect(LocaleUtils.parseLocaleString(''), const Locale('en')); - }); - - test('handles invalid format', () { - expect(LocaleUtils.parseLocaleString('___'), const Locale('en')); - expect(LocaleUtils.parseLocaleString('...'), const Locale('en')); - }); - - test('handles complex UTF-8 suffix', () { - final locale = LocaleUtils.parseLocaleString('zh_CN.GB2312'); - expect(locale.languageCode, 'zh'); - expect(locale.countryCode, 'CN'); - }); - - test('handles multiple delimiters', () { - final locale = LocaleUtils.parseLocaleString('en-US_foo'); - expect(locale.languageCode, 'en'); - expect(locale.countryCode, 'US'); - }); - - test('trims empty parts', () { - final locale = LocaleUtils.parseLocaleString('en__US'); - expect(locale.languageCode, 'en'); - expect(locale.countryCode, 'US'); - }); - - test('handles real-world locale strings', () { - // Single-letter codes are treated as language codes (not mapped to 'en') - expect(LocaleUtils.parseLocaleString('C'), const Locale('C')); - expect(LocaleUtils.parseLocaleString('POSIX'), const Locale('POSIX')); - - final uk = LocaleUtils.parseLocaleString('uk_UA'); - expect(uk.languageCode, 'uk'); - expect(uk.countryCode, 'UA'); - }); - }); - - group('LocaleUtils.getSystemLocale', () { - test('returns valid Locale object', () { - final locale = LocaleUtils.getSystemLocale(); - - // Should return a valid Locale - expect(locale, isA()); - expect(locale.languageCode, isNotEmpty); - }); - - test('handles edge cases gracefully', () { - // This uses Platform.localeName which we can't mock easily, - // but we can verify it doesn't crash and returns fallback - final locale = LocaleUtils.getSystemLocale(); - - // Should never be null - expect(locale, isNotNull); - - // Language code should be valid (2-3 letter code) - expect(locale.languageCode.length, greaterThanOrEqualTo(2)); - expect(locale.languageCode.length, lessThanOrEqualTo(3)); - }); - }); -} diff --git a/test/native_svg_renderer_test.dart b/test/native_svg_renderer_test.dart deleted file mode 100644 index 6c31747..0000000 --- a/test/native_svg_renderer_test.dart +++ /dev/null @@ -1,143 +0,0 @@ -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:meteogram_widget/services/native_svg_renderer.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - group('NativeSvgRenderer', () { - const channel = MethodChannel('org.bortnik.meteogram/svg'); - - setUp(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, null); - }); - - tearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, null); - }); - - test('calls renderSvg with correct arguments', () async { - String? capturedMethod; - Map? capturedArgs; - - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (MethodCall methodCall) async { - capturedMethod = methodCall.method; - capturedArgs = methodCall.arguments as Map; - return Uint8List.fromList([1, 2, 3, 4]); - }); - - await NativeSvgRenderer.renderSvgToPng( - svgString: '', - width: 800, - height: 400, - ); - - expect(capturedMethod, 'renderSvg'); - expect(capturedArgs?['svg'], ''); - expect(capturedArgs?['width'], 800); - expect(capturedArgs?['height'], 400); - }); - - test('returns PNG bytes on success', () async { - final expectedBytes = Uint8List.fromList([137, 80, 78, 71]); // PNG magic - - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (MethodCall methodCall) async { - return expectedBytes; - }); - - final result = await NativeSvgRenderer.renderSvgToPng( - svgString: '', - width: 100, - height: 100, - ); - - expect(result, expectedBytes); - }); - - test('returns null on PlatformException', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (MethodCall methodCall) async { - throw PlatformException(code: 'ERROR', message: 'Render failed'); - }); - - final result = await NativeSvgRenderer.renderSvgToPng( - svgString: '', - width: 100, - height: 100, - ); - - expect(result, isNull); - }); - - test('returns null when native returns null', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (MethodCall methodCall) async { - return null; - }); - - final result = await NativeSvgRenderer.renderSvgToPng( - svgString: '', - width: 100, - height: 100, - ); - - expect(result, isNull); - }); - - test('handles large SVG strings', () async { - final largeSvg = '${'' * 1000}'; - - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (MethodCall methodCall) async { - final args = methodCall.arguments as Map; - expect((args['svg'] as String).length, greaterThan(5000)); - return Uint8List.fromList([1, 2, 3]); - }); - - final result = await NativeSvgRenderer.renderSvgToPng( - svgString: largeSvg, - width: 1000, - height: 500, - ); - - expect(result, isNotNull); - }); - - test('handles various dimensions', () async { - final testCases = [ - (width: 1, height: 1), - (width: 100, height: 50), - (width: 1920, height: 1080), - (width: 4000, height: 2000), - ]; - - for (final testCase in testCases) { - int? capturedWidth; - int? capturedHeight; - - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (MethodCall methodCall) async { - final args = methodCall.arguments as Map; - capturedWidth = args['width'] as int; - capturedHeight = args['height'] as int; - return Uint8List.fromList([0]); - }); - - await NativeSvgRenderer.renderSvgToPng( - svgString: '', - width: testCase.width, - height: testCase.height, - ); - - expect(capturedWidth, testCase.width, - reason: 'Width ${testCase.width} should be passed correctly'); - expect(capturedHeight, testCase.height, - reason: 'Height ${testCase.height} should be passed correctly'); - } - }); - }); -} diff --git a/test/native_svg_service_test.dart b/test/native_svg_service_test.dart new file mode 100644 index 0000000..49e1ff2 --- /dev/null +++ b/test/native_svg_service_test.dart @@ -0,0 +1,179 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meteogram_widget/services/native_svg_service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('NativeSvgService', () { + const channel = MethodChannel('org.bortnik.meteogram/svg'); + + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + group('generateSvg', () { + test('calls generateSvg with correct arguments', () async { + String? capturedMethod; + Map? capturedArgs; + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + capturedMethod = methodCall.method; + capturedArgs = methodCall.arguments as Map; + return 'test'; + }); + + await NativeSvgService.generateSvg( + width: 1000, + height: 500, + isLight: true, + usesFahrenheit: false, + ); + + expect(capturedMethod, 'generateSvg'); + expect(capturedArgs?['width'], 1000); + expect(capturedArgs?['height'], 500); + expect(capturedArgs?['isLight'], true); + expect(capturedArgs?['usesFahrenheit'], false); + }); + + test('passes isLight=false for dark theme', () async { + Map? capturedArgs; + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + capturedArgs = methodCall.arguments as Map; + return 'dark'; + }); + + await NativeSvgService.generateSvg( + width: 800, + height: 400, + isLight: false, + usesFahrenheit: true, + ); + + expect(capturedArgs?['isLight'], false); + expect(capturedArgs?['usesFahrenheit'], true); + }); + + test('returns SVG string on success', () async { + const expectedSvg = ''; + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + return expectedSvg; + }); + + final result = await NativeSvgService.generateSvg( + width: 100, + height: 100, + isLight: true, + usesFahrenheit: false, + ); + + expect(result, expectedSvg); + }); + + test('returns null on PlatformException', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + throw PlatformException(code: 'NO_DATA', message: 'No weather data'); + }); + + final result = await NativeSvgService.generateSvg( + width: 100, + height: 100, + isLight: true, + usesFahrenheit: false, + ); + + expect(result, isNull); + }); + + test('returns null when native returns null', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + return null; + }); + + final result = await NativeSvgService.generateSvg( + width: 100, + height: 100, + isLight: true, + usesFahrenheit: false, + ); + + expect(result, isNull); + }); + }); + + group('generateSvgPair', () { + test('generates both light and dark SVGs', () async { + final calls = >[]; + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + final args = methodCall.arguments as Map; + calls.add(args); + final isLight = args['isLight'] as bool; + return isLight ? 'light' : 'dark'; + }); + + final result = await NativeSvgService.generateSvgPair( + width: 1000, + height: 500, + usesFahrenheit: false, + ); + + expect(calls.length, 2); + expect(calls[0]['isLight'], true); + expect(calls[1]['isLight'], false); + expect(result.light, 'light'); + expect(result.dark, 'dark'); + }); + + test('returns nulls when generation fails', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + throw PlatformException(code: 'ERROR', message: 'Failed'); + }); + + final result = await NativeSvgService.generateSvgPair( + width: 100, + height: 100, + usesFahrenheit: false, + ); + + expect(result.light, isNull); + expect(result.dark, isNull); + }); + + test('passes usesFahrenheit correctly', () async { + final capturedUsesFahrenheit = []; + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + final args = methodCall.arguments as Map; + capturedUsesFahrenheit.add(args['usesFahrenheit'] as bool); + return ''; + }); + + await NativeSvgService.generateSvgPair( + width: 100, + height: 100, + usesFahrenheit: true, + ); + + expect(capturedUsesFahrenheit, [true, true]); + }); + }); + }); +} diff --git a/test/svg_chart_generator_test.dart b/test/svg_chart_generator_test.dart deleted file mode 100644 index 97448b6..0000000 --- a/test/svg_chart_generator_test.dart +++ /dev/null @@ -1,495 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:intl/date_symbol_data_local.dart'; -import 'package:meteogram_widget/services/svg_chart_generator.dart'; -import 'package:meteogram_widget/models/weather_data.dart'; - -void main() { - setUpAll(() async { - // Initialize date formatting for all locales used in tests - await initializeDateFormatting('en_US'); - await initializeDateFormatting('de_DE'); - await initializeDateFormatting('en'); - }); - group('SvgColor', () { - test('constructor sets RGBA values correctly', () { - const color = SvgColor(255, 128, 64, 200); - expect(color.r, 255); - expect(color.g, 128); - expect(color.b, 64); - expect(color.a, 200); - }); - - test('constructor defaults alpha to 255', () { - const color = SvgColor(100, 150, 200); - expect(color.a, 255); - }); - - test('fromArgb extracts components correctly', () { - // ARGB: 0xFFFF6B6B (opaque coral) - final color = SvgColor.fromArgb(0xFFFF6B6B); - expect(color.a, 0xFF); - expect(color.r, 0xFF); - expect(color.g, 0x6B); - expect(color.b, 0x6B); - }); - - test('fromArgb handles transparent colors', () { - // ARGB: 0x80FF0000 (50% transparent red) - final color = SvgColor.fromArgb(0x80FF0000); - expect(color.a, 0x80); - expect(color.r, 0xFF); - expect(color.g, 0x00); - expect(color.b, 0x00); - }); - - test('fromArgb handles fully transparent', () { - final color = SvgColor.fromArgb(0x00FFFFFF); - expect(color.a, 0x00); - expect(color.opacity, 0.0); - }); - - test('toHex produces correct format', () { - const color = SvgColor(255, 107, 107); - expect(color.toHex(), '#ff6b6b'); - }); - - test('toHex pads single digit values', () { - const color = SvgColor(0, 15, 1); - expect(color.toHex(), '#000f01'); - }); - - test('toHex handles black', () { - const color = SvgColor(0, 0, 0); - expect(color.toHex(), '#000000'); - }); - - test('toHex handles white', () { - const color = SvgColor(255, 255, 255); - expect(color.toHex(), '#ffffff'); - }); - - test('opacity returns correct value for fully opaque', () { - const color = SvgColor(100, 100, 100, 255); - expect(color.opacity, 1.0); - }); - - test('opacity returns correct value for 50% transparent', () { - const color = SvgColor(100, 100, 100, 127); - expect(color.opacity, closeTo(0.498, 0.01)); - }); - - test('opacity returns correct value for fully transparent', () { - const color = SvgColor(100, 100, 100, 0); - expect(color.opacity, 0.0); - }); - }); - - group('SvgChartColors', () { - test('light preset has expected temperature line color', () { - expect(SvgChartColors.light.temperatureLine.toHex(), '#ff6b6b'); - }); - - test('dark preset has expected temperature line color', () { - expect(SvgChartColors.dark.temperatureLine.toHex(), '#ff7675'); - }); - - test('light preset has white card background', () { - expect(SvgChartColors.light.cardBackground.toHex(), '#ffffff'); - }); - - test('dark preset has dark card background', () { - expect(SvgChartColors.dark.cardBackground.toHex(), '#2d2d2d'); - }); - - test('withDynamicColors replaces temperature line color', () { - const newTempColor = SvgColor(0, 128, 255); - const newTimeColor = SvgColor(100, 100, 100); - - final colors = SvgChartColors.light.withDynamicColors( - temperatureLine: newTempColor, - timeLabel: newTimeColor, - ); - - expect(colors.temperatureLine.toHex(), '#0080ff'); - }); - - test('withDynamicColors replaces time label color', () { - const newTempColor = SvgColor(255, 0, 0); - const newTimeColor = SvgColor(50, 100, 150); - - final colors = SvgChartColors.light.withDynamicColors( - temperatureLine: newTempColor, - timeLabel: newTimeColor, - ); - - expect(colors.timeLabel.toHex(), '#326496'); - }); - - test('withDynamicColors preserves gradient alpha from original', () { - const newTempColor = SvgColor(0, 255, 0); - const newTimeColor = SvgColor(100, 100, 100); - - final colors = SvgChartColors.light.withDynamicColors( - temperatureLine: newTempColor, - timeLabel: newTimeColor, - ); - - // Original light gradient start alpha is 0x40 (64) - expect(colors.temperatureGradientStart.a, 0x40); - // Gradient uses new color's RGB - expect(colors.temperatureGradientStart.r, 0); - expect(colors.temperatureGradientStart.g, 255); - expect(colors.temperatureGradientStart.b, 0); - }); - - test('withDynamicColors sets gradient end to fully transparent', () { - const newTempColor = SvgColor(128, 64, 32); - const newTimeColor = SvgColor(100, 100, 100); - - final colors = SvgChartColors.light.withDynamicColors( - temperatureLine: newTempColor, - timeLabel: newTimeColor, - ); - - expect(colors.temperatureGradientEnd.a, 0x00); - expect(colors.temperatureGradientEnd.r, 128); - }); - - test('withDynamicColors preserves other colors', () { - const newTempColor = SvgColor(255, 0, 0); - const newTimeColor = SvgColor(0, 255, 0); - - final colors = SvgChartColors.light.withDynamicColors( - temperatureLine: newTempColor, - timeLabel: newTimeColor, - ); - - // Precipitation should be unchanged - expect(colors.precipitationBar.toHex(), SvgChartColors.light.precipitationBar.toHex()); - // Daylight should be unchanged - expect(colors.daylightBar.toHex(), SvgChartColors.light.daylightBar.toHex()); - // Card background should be unchanged - expect(colors.cardBackground.toHex(), SvgChartColors.light.cardBackground.toHex()); - }); - }); - - group('SvgChartGenerator', () { - late SvgChartGenerator generator; - late List testData; - - setUp(() { - generator = SvgChartGenerator(); - // Create 52 hours of test data (typical display range) - final now = DateTime(2024, 1, 15, 12, 0); - testData = List.generate(52, (i) { - return HourlyData( - time: now.add(Duration(hours: i - 6)), // 6 hours past, 46 future - temperature: 10.0 + 5.0 * (i % 12 - 6).abs() / 6, // Varies 10-15°C - precipitation: i % 8 == 0 ? 2.0 : 0.0, // Some precipitation - cloudCover: (i * 10) % 100, // Varying cloud cover - ); - }); - }); - - test('generates valid SVG with empty data', () { - final svg = generator.generate( - data: [], - nowIndex: 0, - latitude: 52.52, - longitude: 13.405, - colors: SvgChartColors.light, - width: 400, - height: 200, - ); - - expect(svg, startsWith('')); - expect(svg, contains('xmlns="http://www.w3.org/2000/svg"')); - expect(svg, contains('viewBox="0 0 400 200"')); - }); - - test('generates valid SVG structure with data', () { - final svg = generator.generate( - data: testData, - nowIndex: 6, - latitude: 52.52, - longitude: 13.405, - colors: SvgChartColors.light, - width: 800, - height: 400, - ); - - expect(svg, startsWith('')); - expect(svg, contains('')); - expect(svg, contains('')); - }); - - test('includes temperature gradient definition', () { - final svg = generator.generate( - data: testData, - nowIndex: 6, - latitude: 52.52, - longitude: 13.405, - colors: SvgChartColors.light, - width: 800, - height: 400, - ); - - expect(svg, contains('id="tempGradient"')); - expect(svg, contains('linearGradient')); - }); - - test('includes temperature line path', () { - final svg = generator.generate( - data: testData, - nowIndex: 6, - latitude: 52.52, - longitude: 13.405, - colors: SvgChartColors.light, - width: 800, - height: 400, - ); - - // Temperature line uses path element - expect(svg, contains('')); - }); - - test('handles nowIndex near end of data', () { - final svg = generator.generate( - data: testData, - nowIndex: testData.length - 10, - latitude: 52.52, - longitude: 13.405, - colors: SvgChartColors.light, - width: 800, - height: 400, - ); - - expect(svg, startsWith('')); - }); - - test('includes precipitation bars when data has precipitation', () { - final svg = generator.generate( - data: testData, - nowIndex: 6, - latitude: 52.52, - longitude: 13.405, - colors: SvgChartColors.light, - width: 800, - height: 400, - ); - - // Precipitation uses rect elements with precipitation color - expect(svg, contains(SvgChartColors.light.precipitationBar.toHex())); - }); - - test('uses past fade mask by default', () { - final svg = generator.generate( - data: testData, - nowIndex: 6, - latitude: 52.52, - longitude: 13.405, - colors: SvgChartColors.light, - width: 800, - height: 400, - ); - - expect(svg, contains('mask="url(#pastFadeMask)"')); - }); - - test('can disable past fade mask', () { - final svg = generator.generate( - data: testData, - nowIndex: 6, - latitude: 52.52, - longitude: 13.405, - colors: SvgChartColors.light, - width: 800, - height: 400, - usePastFade: false, - ); - - expect(svg, isNot(contains('mask="url(#pastFadeMask)"'))); - }); - - test('locale affects time label format', () { - final svgEn = generator.generate( - data: testData, - nowIndex: 6, - latitude: 52.52, - longitude: 13.405, - colors: SvgChartColors.light, - width: 800, - height: 400, - locale: 'en_US', - ); - - final svgDe = generator.generate( - data: testData, - nowIndex: 6, - latitude: 52.52, - longitude: 13.405, - colors: SvgChartColors.light, - width: 800, - height: 400, - locale: 'de_DE', - ); - - // Both should be valid SVGs - expect(svgEn, startsWith('')); - expect(svgDe, endsWith('')); - }); - - test('generates consistent output for same input', () { - final svg1 = generator.generate( - data: testData, - nowIndex: 6, - latitude: 52.52, - longitude: 13.405, - colors: SvgChartColors.light, - width: 800, - height: 400, - ); - - final svg2 = generator.generate( - data: testData, - nowIndex: 6, - latitude: 52.52, - longitude: 13.405, - colors: SvgChartColors.light, - width: 800, - height: 400, - ); - - expect(svg1, equals(svg2)); - }); - - test('different data produces different output', () { - final svg1 = generator.generate( - data: testData, - nowIndex: 6, - latitude: 52.52, - longitude: 13.405, - colors: SvgChartColors.light, - width: 800, - height: 400, - ); - - // Modify temperature in test data - final modifiedData = testData.map((h) => HourlyData( - time: h.time, - temperature: h.temperature + 10, - precipitation: h.precipitation, - cloudCover: h.cloudCover, - )).toList(); - - final svg2 = generator.generate( - data: modifiedData, - nowIndex: 6, - latitude: 52.52, - longitude: 13.405, - colors: SvgChartColors.light, - width: 800, - height: 400, - ); - - expect(svg1, isNot(equals(svg2))); - }); - }); -} diff --git a/test/units_service_test.dart b/test/units_service_test.dart index f22a899..5078453 100644 --- a/test/units_service_test.dart +++ b/test/units_service_test.dart @@ -56,33 +56,6 @@ void main() { }); }); - group('UnitsService.usesInches', () { - test('US uses inches', () { - const locale = Locale('en', 'US'); - expect(UnitsService.usesInches(locale), isTrue); - }); - - test('UK uses inches', () { - const locale = Locale('en', 'GB'); - expect(UnitsService.usesInches(locale), isTrue); - }); - - test('Germany uses mm', () { - const locale = Locale('de', 'DE'); - expect(UnitsService.usesInches(locale), isFalse); - }); - - test('Japan uses mm', () { - const locale = Locale('ja', 'JP'); - expect(UnitsService.usesInches(locale), isFalse); - }); - - test('language-only locale defaults to mm', () { - const locale = Locale('en'); - expect(UnitsService.usesInches(locale), isFalse); - }); - }); - group('UnitsService.formatTemperature', () { test('formats Celsius for metric locales', () { const locale = Locale('de', 'DE'); @@ -140,126 +113,4 @@ void main() { expect(UnitsService.formatTemperatureFromBool(20.4, true), '69°F'); }); }); - - group('UnitsService.formatTemperatureValue', () { - test('returns value without unit for Celsius', () { - const locale = Locale('de', 'DE'); - expect(UnitsService.formatTemperatureValue(20.0, locale), '20'); - expect(UnitsService.formatTemperatureValue(-5.0, locale), '-5'); - }); - - test('returns converted value without unit for Fahrenheit', () { - const locale = Locale('en', 'US'); - expect(UnitsService.formatTemperatureValue(0.0, locale), '32'); - expect(UnitsService.formatTemperatureValue(100.0, locale), '212'); - }); - }); - - group('UnitsService.getTemperatureUnit', () { - test('returns °C for metric locales', () { - const locale = Locale('de', 'DE'); - expect(UnitsService.getTemperatureUnit(locale), '°C'); - }); - - test('returns °F for US locale', () { - const locale = Locale('en', 'US'); - expect(UnitsService.getTemperatureUnit(locale), '°F'); - }); - }); - - group('UnitsService.formatPrecipitation', () { - test('formats mm for metric locales', () { - const locale = Locale('de', 'DE'); - expect(UnitsService.formatPrecipitation(10.0, locale), '10.0 mm'); - expect(UnitsService.formatPrecipitation(0.5, locale), '0.5 mm'); - expect(UnitsService.formatPrecipitation(0.0, locale), '0.0 mm'); - }); - - test('formats inches for US locale', () { - const locale = Locale('en', 'US'); - expect(UnitsService.formatPrecipitation(25.4, locale), '1.00"'); - expect(UnitsService.formatPrecipitation(0.0, locale), '0.00"'); - }); - - test('formats inches for UK locale', () { - const locale = Locale('en', 'GB'); - expect(UnitsService.formatPrecipitation(25.4, locale), '1.00"'); - }); - - test('handles small precipitation amounts', () { - const locale = Locale('de', 'DE'); - expect(UnitsService.formatPrecipitation(0.1, locale), '0.1 mm'); - - const usLocale = Locale('en', 'US'); - // 0.1mm ≈ 0.004 inches - expect(UnitsService.formatPrecipitation(0.1, usLocale), '0.00"'); - }); - - test('handles large precipitation amounts', () { - const locale = Locale('de', 'DE'); - expect(UnitsService.formatPrecipitation(100.0, locale), '100.0 mm'); - - const usLocale = Locale('en', 'US'); - // 100mm ≈ 3.94 inches - expect(UnitsService.formatPrecipitation(100.0, usLocale), '3.94"'); - }); - }); - - group('UnitsService.getPrecipitationUnit', () { - test('returns mm for metric locales', () { - const locale = Locale('de', 'DE'); - expect(UnitsService.getPrecipitationUnit(locale), 'mm'); - }); - - test('returns in for US locale', () { - const locale = Locale('en', 'US'); - expect(UnitsService.getPrecipitationUnit(locale), 'in'); - }); - - test('returns in for UK locale', () { - const locale = Locale('en', 'GB'); - expect(UnitsService.getPrecipitationUnit(locale), 'in'); - }); - }); - - group('UnitsService.convertTemperature', () { - test('returns Celsius unchanged for metric locales', () { - const locale = Locale('de', 'DE'); - expect(UnitsService.convertTemperature(20.0, locale), 20.0); - expect(UnitsService.convertTemperature(-10.0, locale), -10.0); - }); - - test('converts to Fahrenheit for US locale', () { - const locale = Locale('en', 'US'); - expect(UnitsService.convertTemperature(0.0, locale), 32.0); - expect(UnitsService.convertTemperature(100.0, locale), 212.0); - expect(UnitsService.convertTemperature(-40.0, locale), -40.0); - }); - - test('conversion is accurate', () { - const locale = Locale('en', 'US'); - // 20°C = 68°F - expect(UnitsService.convertTemperature(20.0, locale), 68.0); - // 37°C = 98.6°F (body temperature) - expect(UnitsService.convertTemperature(37.0, locale), closeTo(98.6, 0.01)); - }); - }); - - group('UnitsService.convertPrecipitation', () { - test('returns mm unchanged for metric locales', () { - const locale = Locale('de', 'DE'); - expect(UnitsService.convertPrecipitation(10.0, locale), 10.0); - }); - - test('converts to inches for US locale', () { - const locale = Locale('en', 'US'); - expect(UnitsService.convertPrecipitation(25.4, locale), closeTo(1.0, 0.001)); - expect(UnitsService.convertPrecipitation(0.0, locale), 0.0); - }); - - test('converts to inches for UK locale', () { - const locale = Locale('en', 'GB'); - expect(UnitsService.convertPrecipitation(50.8, locale), closeTo(2.0, 0.001)); - }); - }); } diff --git a/test/weather_service_test.dart b/test/weather_service_test.dart deleted file mode 100644 index 0931215..0000000 --- a/test/weather_service_test.dart +++ /dev/null @@ -1,398 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:http/http.dart' as http; -import 'package:http/testing.dart'; -import 'package:meteogram_widget/services/weather_service.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - // Mock HomeWidget method channel - final Map homeWidgetData = {}; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(const MethodChannel('home_widget'), (call) async { - if (call.method == 'saveWidgetData') { - final args = call.arguments as Map; - final id = args['id'] as String?; - final data = args['data']; - if (id != null) { - if (data == null) { - homeWidgetData.remove(id); - } else { - homeWidgetData[id] = data; - } - } - return true; - } else if (call.method == 'getWidgetData') { - final args = call.arguments as Map; - final id = args['id'] as String?; - return id != null ? homeWidgetData[id] : null; - } - return null; - }); - - // Set up SharedPreferences mock for all tests - setUp(() { - SharedPreferences.setMockInitialValues({}); - homeWidgetData.clear(); - }); - - group('WeatherService API responses', () { - test('parses successful API response', () async { - final mockClient = MockClient((request) async { - expect(request.url.host, 'api.open-meteo.com'); - expect(request.url.path, '/v1/forecast'); - expect(request.url.queryParameters['latitude'], '52.52'); - expect(request.url.queryParameters['longitude'], '13.405'); - expect(request.url.queryParameters['timezone'], 'UTC'); - - return http.Response( - jsonEncode(_validWeatherResponse()), - 200, - ); - }); - - final service = WeatherService(client: mockClient); - final data = await service.fetchWeather(52.52, 13.405); - - expect(data.latitude, closeTo(52.52, 0.01)); - expect(data.longitude, closeTo(13.41, 0.01)); - expect(data.timezone, 'UTC'); - expect(data.hourly.length, greaterThan(0)); - }); - - test('parses temperature, precipitation, and cloud cover', () async { - final mockClient = MockClient((request) async { - return http.Response( - jsonEncode(_validWeatherResponse()), - 200, - ); - }); - - final service = WeatherService(client: mockClient); - final data = await service.fetchWeather(52.52, 13.405); - - // First entry is 6 hours in the past: 15.0 + (-6) * 0.5 = 12.0 - expect(data.hourly.first.temperature, 12.0); - expect(data.hourly.first.precipitation, 0.0); - // Cloud cover: 50 + ((-6) % 10) * 5 = 50 + 4 * 5 = 70 (Dart modulo) - expect(data.hourly.first.cloudCover, 70); - }); - - test('throws WeatherException on 404', () async { - final mockClient = MockClient((request) async { - return http.Response('Not Found', 404); - }); - - final service = WeatherService(client: mockClient); - - expect( - () => service.fetchWeather(52.52, 13.405), - throwsA(isA().having( - (e) => e.message, - 'message', - contains('Failed to load weather data'), - )), - ); - }); - - test('throws WeatherException on 500', () async { - final mockClient = MockClient((request) async { - return http.Response('Internal Server Error', 500); - }); - - final service = WeatherService(client: mockClient); - - expect( - () => service.fetchWeather(52.52, 13.405), - throwsA(isA().having( - (e) => e.message, - 'message', - contains('Failed to load weather data'), - )), - ); - }); - - test('throws rate limit exception on 429', () async { - final mockClient = MockClient((request) async { - return http.Response('Too Many Requests', 429); - }); - - final service = WeatherService(client: mockClient); - - expect( - () => service.fetchWeather(52.52, 13.405), - throwsA(isA().having( - (e) => e.message, - 'message', - contains('Rate limited'), - )), - ); - }); - - test('throws WeatherException on timeout', () async { - final mockClient = MockClient((request) async { - await Future.delayed(const Duration(seconds: 10)); - return http.Response('OK', 200); - }); - - final service = WeatherService(client: mockClient); - - expect( - () => service.fetchWeather(52.52, 13.405), - throwsA(isA().having( - (e) => e.message, - 'message', - contains('timed out'), - )), - ); - }, timeout: const Timeout(Duration(seconds: 15))); - - test('throws WeatherException on socket exception', () async { - final mockClient = MockClient((request) async { - throw const SocketException('No internet'); - }); - - final service = WeatherService(client: mockClient); - - expect( - () => service.fetchWeather(52.52, 13.405), - throwsA(isA().having( - (e) => e.message, - 'message', - contains('internet'), - )), - ); - }); - }); - - group('WeatherService caching', () { - test('caches successful response', () async { - var requestCount = 0; - final mockClient = MockClient((request) async { - requestCount++; - return http.Response(jsonEncode(_validWeatherResponse()), 200); - }); - - final service = WeatherService(client: mockClient); - - // First fetch - await service.fetchWeather(52.52, 13.405); - expect(requestCount, 1); - - // Verify cache was saved - final cached = await service.getCachedWeather(); - expect(cached, isNotNull); - expect(cached!.latitude, closeTo(52.52, 0.01)); - }); - - test('returns cached data on API failure', () async { - // First, successfully fetch and cache data - var shouldFail = false; - final mockClient = MockClient((request) async { - if (shouldFail) { - return http.Response('Server Error', 500); - } - return http.Response(jsonEncode(_validWeatherResponse()), 200); - }); - - final service = WeatherService(client: mockClient); - - // First fetch succeeds and caches - await service.fetchWeather(52.52, 13.405); - - // Now make API fail - shouldFail = true; - - // Should return cached data instead of throwing - final data = await service.fetchWeather(52.52, 13.405); - expect(data, isNotNull); - expect(data.latitude, closeTo(52.52, 0.01)); - }); - - test('throws when API fails and no cache available', () async { - final mockClient = MockClient((request) async { - return http.Response('Server Error', 500); - }); - - final service = WeatherService(client: mockClient); - - expect( - () => service.fetchWeather(52.52, 13.405), - throwsA(isA()), - ); - }); - - test('returns null for wrong location cache', () async { - // Pre-populate HomeWidget cache - homeWidgetData['cached_weather'] = jsonEncode(_validWeatherResponse()); - SharedPreferences.setMockInitialValues({ - 'cached_weather_location': '40.71,-74.01', // New York - }); - - final service = WeatherService(client: MockClient((r) async => http.Response('', 500))); - - // Request Berlin but cache is for New York - final cached = await service.getCachedWeather('52.52,13.41'); - expect(cached, isNull); - }); - - test('clearCache removes cached data', () async { - // Pre-populate HomeWidget cache - homeWidgetData['cached_weather'] = jsonEncode(_validWeatherResponse()); - SharedPreferences.setMockInitialValues({ - 'cached_weather_location': '52.52,13.41', - }); - - final service = WeatherService(client: MockClient((r) async => http.Response('', 200))); - - // Verify cache exists - var cached = await service.getCachedWeather(); - expect(cached, isNotNull); - - // Clear cache - await service.clearCache(); - - // Verify cache is gone - cached = await service.getCachedWeather(); - expect(cached, isNull); - }); - - test('isCacheStale returns true when no cache', () async { - final service = WeatherService(client: MockClient((r) async => http.Response('', 200))); - expect(await service.isCacheStale(), isTrue); - }); - - test('isCacheStale returns false for fresh cache', () async { - final freshResponse = _validWeatherResponse(); - freshResponse['fetchedAt'] = DateTime.now().toIso8601String(); - - // Pre-populate HomeWidget cache - homeWidgetData['cached_weather'] = jsonEncode(freshResponse); - SharedPreferences.setMockInitialValues({ - 'cached_weather_location': '52.52,13.41', - }); - - final service = WeatherService(client: MockClient((r) async => http.Response('', 200))); - expect(await service.isCacheStale(), isFalse); - }); - - test('isCacheStale returns true for old cache', () async { - final oldResponse = _validWeatherResponse(); - oldResponse['fetchedAt'] = DateTime.now() - .subtract(const Duration(hours: 2)) - .toIso8601String(); - - // Pre-populate HomeWidget cache - homeWidgetData['cached_weather'] = jsonEncode(oldResponse); - SharedPreferences.setMockInitialValues({ - 'cached_weather_location': '52.52,13.41', - }); - - final service = WeatherService(client: MockClient((r) async => http.Response('', 200))); - expect(await service.isCacheStale(), isTrue); - }); - }); - - group('WeatherService location info caching', () { - test('caches city name', () async { - final service = WeatherService(client: MockClient((r) async => http.Response('', 200))); - - await service.cacheLocationInfo('Berlin', 'gps'); - - expect(await service.getCachedCityName(), 'Berlin'); - expect(await service.getCachedLocationSource(), 'gps'); - }); - - test('handles null city name', () async { - final service = WeatherService(client: MockClient((r) async => http.Response('', 200))); - - await service.cacheLocationInfo(null, 'manual'); - - expect(await service.getCachedCityName(), isNull); - expect(await service.getCachedLocationSource(), 'manual'); - }); - }); - - group('WeatherException', () { - test('toString returns message', () { - final exception = WeatherException('Test error'); - expect(exception.toString(), 'Test error'); - }); - - test('message is accessible', () { - final exception = WeatherException('Network error'); - expect(exception.message, 'Network error'); - }); - }); - - group('WeatherService request parameters', () { - test('sends correct query parameters', () async { - Uri? capturedUri; - final mockClient = MockClient((request) async { - capturedUri = request.url; - return http.Response(jsonEncode(_validWeatherResponse()), 200); - }); - - final service = WeatherService(client: mockClient); - await service.fetchWeather(37.7749, -122.4194); - - expect(capturedUri, isNotNull); - expect(capturedUri!.queryParameters['latitude'], '37.7749'); - expect(capturedUri!.queryParameters['longitude'], '-122.4194'); - expect(capturedUri!.queryParameters['hourly'], 'temperature_2m,precipitation,cloud_cover'); - expect(capturedUri!.queryParameters['timezone'], 'UTC'); - expect(capturedUri!.queryParameters['past_hours'], '6'); - expect(capturedUri!.queryParameters['forecast_days'], '2'); - }); - - test('handles negative coordinates', () async { - Uri? capturedUri; - final mockClient = MockClient((request) async { - capturedUri = request.url; - return http.Response(jsonEncode(_validWeatherResponse()), 200); - }); - - final service = WeatherService(client: mockClient); - await service.fetchWeather(-33.8688, 151.2093); // Sydney - - expect(capturedUri!.queryParameters['latitude'], '-33.8688'); - expect(capturedUri!.queryParameters['longitude'], '151.2093'); - }); - }); -} - -/// Generate a valid weather API response for testing. -Map _validWeatherResponse() { - final now = DateTime.now().toUtc(); - final times = []; - final temps = []; - final precip = []; - final clouds = []; - - // Generate 54 hours of data (6 past + 48 forecast) - for (var i = -6; i < 48; i++) { - final time = now.add(Duration(hours: i)); - times.add(time.toIso8601String().replaceAll('Z', '')); - temps.add(15.0 + i * 0.5); - precip.add(i % 8 == 0 ? 1.5 : 0.0); - clouds.add(50 + (i % 10) * 5); - } - - return { - 'latitude': 52.52, - 'longitude': 13.41, - 'timezone': 'UTC', - 'fetchedAt': now.toIso8601String(), - 'hourly': { - 'time': times, - 'temperature_2m': temps, - 'precipitation': precip, - 'cloud_cover': clouds, - }, - }; -} diff --git a/test/widget_service_test.dart b/test/widget_service_test.dart deleted file mode 100644 index 686d980..0000000 --- a/test/widget_service_test.dart +++ /dev/null @@ -1,108 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:meteogram_widget/services/widget_service.dart'; - -void main() { - group('Dimension constants alignment', () { - test('widget_service defaults match background_service defaults', () { - // These constants must match across all code paths to avoid - // dimension mismatches between app and widget rendering - expect(kDefaultWidthPx, 1000); - expect(kDefaultHeightPx, 500); - }); - - test('default aspect ratio is 2:1', () { - // The chart expects a 2:1 aspect ratio - expect(kDefaultWidthPx / kDefaultHeightPx, 2.0); - }); - }); - - group('WidgetDimensions', () { - test('calculates logical size correctly', () { - const dims = WidgetDimensions( - widthPx: 1000, - heightPx: 500, - density: 2.0, - ); - - expect(dims.logicalSize.width, 500.0); - expect(dims.logicalSize.height, 250.0); - }); - - test('handles high density screens', () { - const dims = WidgetDimensions( - widthPx: 1319, - heightPx: 774, - density: 4.1625, - ); - - // Logical size should be physical / density - expect(dims.logicalSize.width, closeTo(316.9, 0.1)); - expect(dims.logicalSize.height, closeTo(186.0, 0.1)); - }); - - test('toString includes all values', () { - const dims = WidgetDimensions( - widthPx: 1000, - heightPx: 500, - density: 2.0, - ); - - final str = dims.toString(); - expect(str, contains('1000')); - expect(str, contains('500')); - expect(str, contains('2.0')); - }); - }); - - group('Dimension fallback logic', () { - // These tests verify the fallback patterns used in widget_service.dart - // and background_service.dart for handling missing/invalid dimensions - - test('null dimensions should use defaults', () { - int? widthPx; - int? heightPx; - - // Mirror the fallback logic from generateAndSaveSvgCharts - final width = widthPx ?? kDefaultWidthPx; - final height = heightPx ?? kDefaultHeightPx; - - expect(width, 1000); - expect(height, 500); - }); - - test('zero dimensions should use defaults', () { - var widthPx = 0; - var heightPx = 0; - - // Mirror the fallback logic from background_service.dart - if (widthPx <= 0) widthPx = kDefaultWidthPx; - if (heightPx <= 0) heightPx = kDefaultHeightPx; - - expect(widthPx, 1000); - expect(heightPx, 500); - }); - - test('negative dimensions should use defaults', () { - var widthPx = -100; - var heightPx = -50; - - if (widthPx <= 0) widthPx = kDefaultWidthPx; - if (heightPx <= 0) heightPx = kDefaultHeightPx; - - expect(widthPx, 1000); - expect(heightPx, 500); - }); - - test('valid dimensions should be preserved', () { - var widthPx = 1319; - var heightPx = 774; - - // Should not trigger fallback - if (widthPx <= 0) widthPx = kDefaultWidthPx; - if (heightPx <= 0) heightPx = kDefaultHeightPx; - - expect(widthPx, 1319); - expect(heightPx, 774); - }); - }); -}