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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 25 additions & 23 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand All @@ -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
Expand Down Expand Up @@ -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

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

Expand Down
120 changes: 120 additions & 0 deletions android/app/src/main/kotlin/org/bortnik/meteogram/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -27,6 +32,29 @@ class MainActivity : FlutterActivity() {

MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
when (call.method) {
"generateSvg" -> {
val width = call.argument<Int>("width") ?: 0
val height = call.argument<Int>("height") ?: 0
val isLight = call.argument<Boolean>("isLight") ?: true
val usesFahrenheit = call.argument<Boolean>("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<String>("svg")
val width = call.argument<Int>("width") ?: 0
Expand All @@ -44,11 +72,103 @@ class MainActivity : FlutterActivity() {
result.error("RENDER_ERROR", e.message, null)
}
}
"fetchWeather" -> {
val latitude = call.argument<Double>("latitude")
val longitude = call.argument<Double>("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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
Expand Down
Loading