mirror of
https://github.com/PranshulGG/WeatherMaster.git
synced 2026-10-22 02:52:37 -05:00
539 lines
19 KiB
Dart
539 lines
19 KiB
Dart
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<Box> _openBox() async {
|
|
if (!Hive.isBoxOpen(_boxName)) {
|
|
return await Hive.openBox(_boxName);
|
|
}
|
|
return Hive.box(_boxName);
|
|
}
|
|
|
|
Future<Map<String, dynamic>?> 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 = <Future<http.Response>>[
|
|
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<String, dynamic>;
|
|
final airQualityData =
|
|
json.decode(responses[1].body) as Map<String, dynamic>;
|
|
final astronomyData = astronomyUri != null
|
|
? json.decode(responses[2].body) as Map<String, dynamic>
|
|
: {};
|
|
// Check if we need fallback data for missing fields
|
|
Map<String, dynamic> 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<String, dynamic> weatherData) {
|
|
// Check current data completeness
|
|
final current = weatherData['current'] as Map<String, dynamic>?;
|
|
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<String, dynamic>?;
|
|
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<String, dynamic>?;
|
|
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<Map<String, dynamic>> _fetchWithFallback(
|
|
double lat,
|
|
double lon,
|
|
String timezone,
|
|
Map<String, dynamic> 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<String, dynamic>;
|
|
|
|
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<String, dynamic> _mergeWeatherData(
|
|
Map<String, dynamic> primary, Map<String, dynamic> fallback) {
|
|
final merged = Map<String, dynamic>.from(primary);
|
|
|
|
// Merge current data
|
|
merged['current'] = _mergeSection(
|
|
primary['current'] as Map<String, dynamic>?,
|
|
fallback['current'] as Map<String, dynamic>?);
|
|
|
|
// Merge hourly data
|
|
merged['hourly'] = _mergeSection(primary['hourly'] as Map<String, dynamic>?,
|
|
fallback['hourly'] as Map<String, dynamic>?);
|
|
|
|
// Merge daily data
|
|
merged['daily'] = _mergeSection(primary['daily'] as Map<String, dynamic>?,
|
|
fallback['daily'] as Map<String, dynamic>?);
|
|
|
|
// 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<String, dynamic> _mergeSection(
|
|
Map<String, dynamic>? primary, Map<String, dynamic>? fallback) {
|
|
if (primary == null && fallback == null) return {};
|
|
if (primary == null) return Map<String, dynamic>.from(fallback!);
|
|
if (fallback == null) return Map<String, dynamic>.from(primary);
|
|
|
|
final merged = Map<String, dynamic>.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 = <dynamic>[];
|
|
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<T extends num>(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<String, dynamic> sanitizeCurrent(Map? current) {
|
|
current ??= {};
|
|
return {
|
|
'temperature_2m': nullSafeValue<double>(current['temperature_2m']),
|
|
'apparent_temperature':
|
|
nullSafeValue<double>(current['apparent_temperature']),
|
|
'pressure_msl': nullSafeValue<double>(current['pressure_msl']),
|
|
'relative_humidity_2m': nullSafeValue(current['relative_humidity_2m']),
|
|
'precipitation': nullSafeValue<double>(current['precipitation']),
|
|
'weather_code': nullSafeValue<int>(current['weather_code']),
|
|
'cloud_cover': nullSafeValue<int>(current['cloud_cover']),
|
|
'wind_speed_10m': nullSafeValue<double>(current['wind_speed_10m']),
|
|
'wind_direction_10m': nullSafeValue<int>(current['wind_direction_10m']),
|
|
'wind_gusts_10m': nullSafeValue<double>(current['wind_gusts_10m']),
|
|
'is_day': nullSafeValue<int>(current['is_day']),
|
|
};
|
|
}
|
|
|
|
Map<String, dynamic> sanitizeHourly(Map? hourly) {
|
|
hourly ??= {};
|
|
return {
|
|
'time': (hourly['time'] as List?) ?? [],
|
|
'wind_speed_10m': (hourly['wind_speed_10m'] as List?)
|
|
?.map((v) => nullSafeValue<double>(v))
|
|
.toList() ??
|
|
[],
|
|
'wind_direction_10m': (hourly['wind_direction_10m'] as List?)
|
|
?.map((v) => nullSafeValue<int>(v))
|
|
.toList() ??
|
|
[],
|
|
'relative_humidity_2m': (hourly['relative_humidity_2m'] as List?)
|
|
?.map((v) => nullSafeValue<int>(v))
|
|
.toList() ??
|
|
[],
|
|
'pressure_msl': (hourly['pressure_msl'] as List?)
|
|
?.map((v) => nullSafeValue<double>(v))
|
|
.toList() ??
|
|
[],
|
|
'cloud_cover': (hourly['cloud_cover'] as List?)
|
|
?.map((v) => nullSafeValue<int>(v))
|
|
.toList() ??
|
|
[],
|
|
'temperature_2m': (hourly['temperature_2m'] as List?)
|
|
?.map((v) => nullSafeValue<double>(v))
|
|
.toList() ??
|
|
[],
|
|
'dew_point_2m': (hourly['dew_point_2m'] as List?)
|
|
?.map((v) => nullSafeValue<double>(v))
|
|
.toList() ??
|
|
[],
|
|
'apparent_temperature': (hourly['apparent_temperature'] as List?)
|
|
?.map((v) => nullSafeValue<double>(v))
|
|
.toList() ??
|
|
[],
|
|
'precipitation_probability':
|
|
(hourly['precipitation_probability'] as List?)
|
|
?.map((v) => nullSafeValue<int>(v))
|
|
.toList() ??
|
|
[],
|
|
'precipitation': (hourly['precipitation'] as List?)
|
|
?.map((v) => nullSafeValue<double>(v))
|
|
.toList() ??
|
|
[],
|
|
'weather_code': (hourly['weather_code'] as List?)
|
|
?.map((v) => nullSafeValue<int>(v))
|
|
.toList() ??
|
|
[],
|
|
'visibility': (hourly['visibility'] as List?)
|
|
?.map((v) => nullSafeValue<double>(v))
|
|
.toList() ??
|
|
[],
|
|
'uv_index': (hourly['uv_index'] as List?)
|
|
?.map((v) => nullSafeValue<double>(v))
|
|
.toList() ??
|
|
[],
|
|
};
|
|
}
|
|
|
|
Map<String, dynamic> sanitizeDaily(Map? daily) {
|
|
daily ??= {};
|
|
return {
|
|
'time': (daily['time'] as List?) ?? [],
|
|
'weather_code': (daily['weather_code'] as List?)
|
|
?.map((v) => nullSafeValue<int>(v))
|
|
.toList() ??
|
|
[],
|
|
'temperature_2m_max': (daily['temperature_2m_max'] as List?)
|
|
?.map((v) => nullSafeValue<double>(v))
|
|
.toList() ??
|
|
[],
|
|
'temperature_2m_min': (daily['temperature_2m_min'] as List?)
|
|
?.map((v) => nullSafeValue<double>(v))
|
|
.toList() ??
|
|
[],
|
|
'sunrise': (daily['sunrise'] as List?) ?? [],
|
|
'sunset': (daily['sunset'] as List?) ?? [],
|
|
'daylight_duration': (daily['daylight_duration'] as List?)
|
|
?.map((v) => nullSafeValue<double>(v))
|
|
.toList() ??
|
|
[],
|
|
'uv_index_max': (daily['uv_index_max'] as List?)
|
|
?.map((v) => nullSafeValue<double>(v))
|
|
.toList() ??
|
|
[],
|
|
'precipitation_sum': (daily['precipitation_sum'] as List?)
|
|
?.map((v) => nullSafeValue<double>(v))
|
|
.toList() ??
|
|
[],
|
|
'precipitation_probability_max':
|
|
(daily['precipitation_probability_max'] as List?)
|
|
?.map((v) => nullSafeValue<int>(v))
|
|
.toList() ??
|
|
[],
|
|
'precipitation_hours': (daily['precipitation_hours'] as List?)
|
|
?.map((v) => nullSafeValue<double>(v))
|
|
.toList() ??
|
|
[],
|
|
'wind_speed_10m_max': (daily['wind_speed_10m_max'] as List?)
|
|
?.map((v) => nullSafeValue<double>(v))
|
|
.toList() ??
|
|
[],
|
|
'wind_gusts_10m_max': (daily['wind_gusts_10m_max'] as List?)
|
|
?.map((v) => nullSafeValue<double>(v))
|
|
.toList() ??
|
|
[],
|
|
};
|
|
}
|
|
}
|