import 'dart:convert'; import 'dart:developer'; import 'package:http/http.dart' as http; import 'package:hive/hive.dart'; import 'package:lat_lng_to_timezone/lat_lng_to_timezone.dart' as tzmap; import '../utils/preferences_helper.dart'; import 'package:flutter/material.dart'; import '../screens/meteo_models.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'dart:async'; class WeatherService { static const String _boxName = 'weatherMasterCache'; Future _openBox() async { if (!Hive.isBoxOpen(_boxName)) { return await Hive.openBox(_boxName); } return Hive.box(_boxName); } Future?> fetchWeather(double lat, double lon, {String? locationName, BuildContext? context, bool isOnlyView = false, bool isBackground = false}) async { final timezone = tzmap.latLngToTimezoneString(lat, lon); final key = locationName ?? 'loc_${lat}_${lon}'; final box = await _openBox(); final selectedModel = PreferencesHelper.getString("selectedWeatherModel") ?? "best_match"; final uri = Uri.parse('https://api.open-meteo.com/v1/forecast') .replace(queryParameters: { 'latitude': lat.toString(), 'longitude': lon.toString(), 'current': 'temperature_2m,is_day,apparent_temperature,pressure_msl,relative_humidity_2m,precipitation,weather_code,cloud_cover,wind_speed_10m,wind_direction_10m,wind_gusts_10m', 'hourly': 'wind_speed_10m,wind_direction_10m,relative_humidity_2m,pressure_msl,cloud_cover,temperature_2m,dew_point_2m,apparent_temperature,precipitation_probability,precipitation,weather_code,visibility,uv_index', 'daily': 'weather_code,temperature_2m_max,temperature_2m_min,sunrise,sunset,daylight_duration,uv_index_max,precipitation_sum,precipitation_probability_max,precipitation_hours,wind_speed_10m_max,wind_gusts_10m_max', 'timezone': timezone, 'forecast_days': '7', 'models': selectedModel }); final airQualityUri = Uri.parse('https://air-quality-api.open-meteo.com/v1/air-quality') .replace(queryParameters: { 'latitude': lat.toString(), 'longitude': lon.toString(), 'current': 'us_aqi,european_aqi,pm10,pm2_5,carbon_monoxide,nitrogen_dioxide,sulphur_dioxide,ozone,alder_pollen,birch_pollen,grass_pollen,mugwort_pollen,olive_pollen,ragweed_pollen', 'timezone': timezone, 'forecast_hours': '1', }); Uri? astronomyUri; if (isBackground == false) { astronomyUri = Uri.parse( 'https://api.weatherapi.com/v1/astronomy.json?key=${dotenv.env['API_KEY_WEATHERAPI'].toString()}&q=$lat,$lon'); } // Prepare list of HTTP requests try { final requests = >[ http.get(uri).timeout(const Duration(seconds: 15)), http.get(airQualityUri).timeout(const Duration(seconds: 15)), if (astronomyUri != null) http.get(astronomyUri).timeout(const Duration(seconds: 15)), ]; final responses = await Future.wait(requests); for (var response in responses) { if (response.statusCode < 200 || response.statusCode >= 300) { throw Exception( 'HTTP request failed with status: ${response.statusCode}'); } } if (astronomyUri != null) log("Astronomy response: ${responses[2].body}"); // Parse responses final weatherData = json.decode(responses[0].body) as Map; final airQualityData = json.decode(responses[1].body) as Map; final astronomyData = astronomyUri != null ? json.decode(responses[2].body) as Map : {}; // Check if we need fallback data for missing fields Map finalWeatherData = weatherData; if (selectedModel != "best_match" && _hasIncompleteData(weatherData)) { finalWeatherData = await _fetchWithFallback( lat, lon, timezone, weatherData, selectedModel); } finalWeatherData['current'] = sanitizeCurrent(finalWeatherData['current']); finalWeatherData['hourly'] = sanitizeHourly(finalWeatherData['hourly']); finalWeatherData['daily'] = sanitizeDaily(finalWeatherData['daily']); if (finalWeatherData['error'] == true || airQualityData['error'] == true || astronomyData['error'] == true) { final reason = finalWeatherData['reason'] ?? airQualityData['reason'] ?? astronomyData['reason'] ?? 'Unknown error'; if (context != null) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( duration: Duration(seconds: 10), content: Text("$reason. Please change your model"), action: SnackBarAction( label: 'Change model', onPressed: () { Navigator.of(context).push( MaterialPageRoute(builder: (_) => const MeteoModelsPage()), ); }, ), ), ); } return null; } final combinedDataForView = { ...finalWeatherData, 'air_quality': airQualityData, 'astronomy': astronomyData, }; final nowForView = DateTime.now().toIso8601String(); if (isOnlyView) { log("Only viewing data for $key. Not saving to cache."); return { 'data': combinedDataForView, 'last_updated': nowForView, 'from_cache': false, }; } final existingJson = box.get(key); if (existingJson != null) { final cachedMap = json.decode(existingJson); final cachedData = cachedMap['data']; final lastUpdated = cachedMap['last_updated']; if (json.encode(cachedData['current']) == json.encode(finalWeatherData['current']) && json.encode(cachedData['air_quality'] ?? {}) == json.encode(airQualityData) && json.encode(cachedData['astronomy'] ?? {}) == json.encode(astronomyData)) { log("Hive: No update needed for $key"); return { 'data': cachedData, 'last_updated': lastUpdated, 'from_cache': true, }; } } final now = DateTime.now().toIso8601String(); final combinedData = { ...finalWeatherData, 'air_quality': airQualityData, 'astronomy': astronomyData, }; final wrappedData = { 'data': combinedData, 'last_updated': now, }; await box.put(key, json.encode(wrappedData)); log("Hive: Updated cache for $key at $now"); return { 'data': combinedData, 'last_updated': now, 'from_cache': false, }; } on TimeoutException catch (_) { throw Exception("Request timed out after 15 seconds"); } catch (e) { throw Exception('WeatherService.fetchWeather failed: $e'); } } /// Check if weather data has missing or incomplete fields bool _hasIncompleteData(Map weatherData) { // Check current data completeness final current = weatherData['current'] as Map?; if (current == null) return true; // All fields requested in the API call that should be available final allCurrentFields = [ 'temperature_2m', 'is_day', 'apparent_temperature', 'pressure_msl', 'relative_humidity_2m', 'precipitation', 'weather_code', 'cloud_cover', 'wind_speed_10m', 'wind_direction_10m', 'wind_gusts_10m' ]; // Check for missing current fields int missingCurrentFields = 0; for (final field in allCurrentFields) { if (current[field] == null) { missingCurrentFields++; } } // Check hourly data completeness final hourly = weatherData['hourly'] as Map?; if (hourly == null) return true; final allHourlyFields = [ 'wind_speed_10m', 'wind_direction_10m', 'relative_humidity_2m', 'pressure_msl', 'cloud_cover', 'temperature_2m', 'dew_point_2m', 'apparent_temperature', 'precipitation_probability', 'precipitation', 'weather_code', 'visibility', 'uv_index' ]; // Check for missing hourly fields int missingHourlyFields = 0; for (final field in allHourlyFields) { final data = hourly[field] as List?; if (data == null || data.isEmpty || !data.any((value) => value != null)) { missingHourlyFields++; } } // Check daily data completeness final daily = weatherData['daily'] as Map?; if (daily == null) return true; final allDailyFields = [ 'weather_code', 'temperature_2m_max', 'temperature_2m_min', 'sunrise', 'sunset', 'daylight_duration', 'uv_index_max', 'precipitation_sum', 'precipitation_probability_max', 'precipitation_hours', 'wind_speed_10m_max', 'wind_gusts_10m_max' ]; // Check for missing daily fields int missingDailyFields = 0; for (final field in allDailyFields) { final data = daily[field] as List?; if (data == null || data.isEmpty || !data.any((value) => value != null)) { missingDailyFields++; } } // Trigger fallback if any fields are missing final totalMissing = missingCurrentFields + missingHourlyFields + missingDailyFields; return totalMissing > 0; } /// Fetch weather data with fallback to best_match for missing fields Future> _fetchWithFallback( double lat, double lon, String timezone, Map primaryData, String selectedModel) async { try { // Fetch fallback data using best_match final fallbackUri = Uri.parse('https://api.open-meteo.com/v1/forecast') .replace(queryParameters: { 'latitude': lat.toString(), 'longitude': lon.toString(), 'current': 'temperature_2m,is_day,apparent_temperature,pressure_msl,relative_humidity_2m,precipitation,weather_code,cloud_cover,wind_speed_10m,wind_direction_10m,wind_gusts_10m', 'hourly': 'wind_speed_10m,wind_direction_10m,relative_humidity_2m,pressure_msl,cloud_cover,temperature_2m,dew_point_2m,apparent_temperature,precipitation_probability,precipitation,weather_code,visibility,uv_index', 'daily': 'weather_code,temperature_2m_max,temperature_2m_min,sunrise,sunset,daylight_duration,uv_index_max,precipitation_sum,precipitation_probability_max,precipitation_hours,wind_speed_10m_max,wind_gusts_10m_max', 'timezone': timezone, 'forecast_days': '7', 'models': 'best_match' }); final fallbackResponse = await http.get(fallbackUri); final fallbackData = json.decode(fallbackResponse.body) as Map; if (fallbackData['error'] == true) { return primaryData; } // Merge data: primary model takes precedence, fallback fills gaps return _mergeWeatherData(primaryData, fallbackData); } catch (e) { return primaryData; } } /// Merge primary weather data with fallback data, prioritizing primary data Map _mergeWeatherData( Map primary, Map fallback) { final merged = Map.from(primary); // Merge current data merged['current'] = _mergeSection( primary['current'] as Map?, fallback['current'] as Map?); // Merge hourly data merged['hourly'] = _mergeSection(primary['hourly'] as Map?, fallback['hourly'] as Map?); // Merge daily data merged['daily'] = _mergeSection(primary['daily'] as Map?, fallback['daily'] as Map?); // Keep other fields from primary, fallback if missing for (final key in fallback.keys) { if (!merged.containsKey(key) || merged[key] == null) { merged[key] = fallback[key]; } } return merged; } /// Merge a specific section (current/hourly/daily) with fallback data Map _mergeSection( Map? primary, Map? fallback) { if (primary == null && fallback == null) return {}; if (primary == null) return Map.from(fallback!); if (fallback == null) return Map.from(primary); final merged = Map.from(primary); for (final key in fallback.keys) { if (!merged.containsKey(key) || merged[key] == null) { merged[key] = fallback[key]; } else if (merged[key] is List) { // For array fields, merge element by element, filling null values from fallback final primaryList = merged[key] as List; final fallbackList = fallback[key] as List; // Create a new list with the same length as primary, filling nulls from fallback final mergedList = []; final maxLength = primaryList.length > fallbackList.length ? primaryList.length : fallbackList.length; for (int i = 0; i < maxLength; i++) { final primaryValue = i < primaryList.length ? primaryList[i] : null; final fallbackValue = i < fallbackList.length ? fallbackList[i] : null; // Use primary value if not null, otherwise use fallback mergedList.add(primaryValue ?? fallbackValue); } merged[key] = mergedList; } } return merged; } T nullSafeValue(dynamic value) { if (value == null) { if (T == int) return 0000 as T; if (T == double) return 0.0000001 as T; return 0 as T; } if (value is T) return value; if (value is num) { if (T == int) return value.toInt() as T; if (T == double) return value.toDouble() as T; } return T == int ? 0 as T : 0.0000001 as T; } Map sanitizeCurrent(Map? current) { current ??= {}; return { 'temperature_2m': nullSafeValue(current['temperature_2m']), 'apparent_temperature': nullSafeValue(current['apparent_temperature']), 'pressure_msl': nullSafeValue(current['pressure_msl']), 'relative_humidity_2m': nullSafeValue(current['relative_humidity_2m']), 'precipitation': nullSafeValue(current['precipitation']), 'weather_code': nullSafeValue(current['weather_code']), 'cloud_cover': nullSafeValue(current['cloud_cover']), 'wind_speed_10m': nullSafeValue(current['wind_speed_10m']), 'wind_direction_10m': nullSafeValue(current['wind_direction_10m']), 'wind_gusts_10m': nullSafeValue(current['wind_gusts_10m']), 'is_day': nullSafeValue(current['is_day']), }; } Map sanitizeHourly(Map? hourly) { hourly ??= {}; return { 'time': (hourly['time'] as List?) ?? [], 'wind_speed_10m': (hourly['wind_speed_10m'] as List?) ?.map((v) => nullSafeValue(v)) .toList() ?? [], 'wind_direction_10m': (hourly['wind_direction_10m'] as List?) ?.map((v) => nullSafeValue(v)) .toList() ?? [], 'relative_humidity_2m': (hourly['relative_humidity_2m'] as List?) ?.map((v) => nullSafeValue(v)) .toList() ?? [], 'pressure_msl': (hourly['pressure_msl'] as List?) ?.map((v) => nullSafeValue(v)) .toList() ?? [], 'cloud_cover': (hourly['cloud_cover'] as List?) ?.map((v) => nullSafeValue(v)) .toList() ?? [], 'temperature_2m': (hourly['temperature_2m'] as List?) ?.map((v) => nullSafeValue(v)) .toList() ?? [], 'dew_point_2m': (hourly['dew_point_2m'] as List?) ?.map((v) => nullSafeValue(v)) .toList() ?? [], 'apparent_temperature': (hourly['apparent_temperature'] as List?) ?.map((v) => nullSafeValue(v)) .toList() ?? [], 'precipitation_probability': (hourly['precipitation_probability'] as List?) ?.map((v) => nullSafeValue(v)) .toList() ?? [], 'precipitation': (hourly['precipitation'] as List?) ?.map((v) => nullSafeValue(v)) .toList() ?? [], 'weather_code': (hourly['weather_code'] as List?) ?.map((v) => nullSafeValue(v)) .toList() ?? [], 'visibility': (hourly['visibility'] as List?) ?.map((v) => nullSafeValue(v)) .toList() ?? [], 'uv_index': (hourly['uv_index'] as List?) ?.map((v) => nullSafeValue(v)) .toList() ?? [], }; } Map sanitizeDaily(Map? daily) { daily ??= {}; return { 'time': (daily['time'] as List?) ?? [], 'weather_code': (daily['weather_code'] as List?) ?.map((v) => nullSafeValue(v)) .toList() ?? [], 'temperature_2m_max': (daily['temperature_2m_max'] as List?) ?.map((v) => nullSafeValue(v)) .toList() ?? [], 'temperature_2m_min': (daily['temperature_2m_min'] as List?) ?.map((v) => nullSafeValue(v)) .toList() ?? [], 'sunrise': (daily['sunrise'] as List?) ?? [], 'sunset': (daily['sunset'] as List?) ?? [], 'daylight_duration': (daily['daylight_duration'] as List?) ?.map((v) => nullSafeValue(v)) .toList() ?? [], 'uv_index_max': (daily['uv_index_max'] as List?) ?.map((v) => nullSafeValue(v)) .toList() ?? [], 'precipitation_sum': (daily['precipitation_sum'] as List?) ?.map((v) => nullSafeValue(v)) .toList() ?? [], 'precipitation_probability_max': (daily['precipitation_probability_max'] as List?) ?.map((v) => nullSafeValue(v)) .toList() ?? [], 'precipitation_hours': (daily['precipitation_hours'] as List?) ?.map((v) => nullSafeValue(v)) .toList() ?? [], 'wind_speed_10m_max': (daily['wind_speed_10m_max'] as List?) ?.map((v) => nullSafeValue(v)) .toList() ?? [], 'wind_gusts_10m_max': (daily['wind_gusts_10m_max'] as List?) ?.map((v) => nullSafeValue(v)) .toList() ?? [], }; } }