// Models for the Local Intel Service (OpenMeteo + Overpass API data) /// Weather conditions from OpenMeteo API class WeatherConditions { final double temperature; final int weatherCode; final double uvIndex; final double windSpeed; final int humidity; final double feelsLike; final DateTime timestamp; WeatherConditions({ required this.temperature, required this.weatherCode, required this.uvIndex, required this.windSpeed, required this.humidity, required this.feelsLike, required this.timestamp, }); factory WeatherConditions.fromJson(Map json) { final current = json['current'] as Map? ?? {}; return WeatherConditions( temperature: (current['temperature_2m'] as num?)?.toDouble() ?? 0.0, weatherCode: (current['weather_code'] as num?)?.toInt() ?? 0, uvIndex: (current['uv_index'] as num?)?.toDouble() ?? 0.0, windSpeed: (current['wind_speed_10m'] as num?)?.toDouble() ?? 0.0, humidity: (current['relative_humidity_2m'] as num?)?.toInt() ?? 0, feelsLike: (current['apparent_temperature'] as num?)?.toDouble() ?? 0.0, timestamp: DateTime.tryParse(current['time'] ?? '') ?? DateTime.now(), ); } /// Weather code to human-readable description String get weatherDescription { switch (weatherCode) { case 0: return 'Clear sky'; case 1: return 'Mainly clear'; case 2: return 'Partly cloudy'; case 3: return 'Overcast'; case 45: case 48: return 'Foggy'; case 51: case 53: case 55: return 'Drizzle'; case 56: case 57: return 'Freezing drizzle'; case 61: case 63: case 65: return 'Rain'; case 66: case 67: return 'Freezing rain'; case 71: case 73: case 75: return 'Snowfall'; case 77: return 'Snow grains'; case 80: case 81: case 82: return 'Rain showers'; case 85: case 86: return 'Snow showers'; case 95: return 'Thunderstorm'; case 96: case 99: return 'Thunderstorm with hail'; default: return 'Unknown'; } } /// Weather code to icon name String get weatherIcon { switch (weatherCode) { case 0: return 'wb_sunny'; case 1: case 2: return 'partly_cloudy_day'; case 3: return 'cloud'; case 45: case 48: return 'foggy'; case 51: case 53: case 55: case 56: case 57: case 61: case 63: case 65: case 66: case 67: case 80: case 81: case 82: return 'rainy'; case 71: case 73: case 75: case 77: case 85: case 86: return 'ac_unit'; case 95: case 96: case 99: return 'thunderstorm'; default: return 'cloud'; } } /// UV Index risk level String get uvRiskLevel { if (uvIndex < 3) return 'Low'; if (uvIndex < 6) return 'Moderate'; if (uvIndex < 8) return 'High'; if (uvIndex < 11) return 'Very High'; return 'Extreme'; } } /// Air quality and environmental hazards from OpenMeteo Air Quality API class EnvironmentalHazards { final int aqi; // US EPA AQI final double pm25; final double pm10; final int grassPollen; final int birchPollen; final int olivePollen; final int ragweedPollen; final double uvIndex; final DateTime timestamp; EnvironmentalHazards({ required this.aqi, required this.pm25, required this.pm10, required this.grassPollen, required this.birchPollen, required this.olivePollen, required this.ragweedPollen, required this.uvIndex, required this.timestamp, }); factory EnvironmentalHazards.fromJson(Map json) { final current = json['current'] as Map? ?? {}; return EnvironmentalHazards( aqi: (current['us_aqi'] as num?)?.toInt() ?? 0, pm25: (current['pm2_5'] as num?)?.toDouble() ?? 0.0, pm10: (current['pm10'] as num?)?.toDouble() ?? 0.0, grassPollen: (current['grass_pollen'] as num?)?.toInt() ?? 0, birchPollen: (current['birch_pollen'] as num?)?.toInt() ?? 0, olivePollen: (current['olive_pollen'] as num?)?.toInt() ?? 0, ragweedPollen: (current['ragweed_pollen'] as num?)?.toInt() ?? 0, uvIndex: (current['uv_index'] as num?)?.toDouble() ?? 0.0, timestamp: DateTime.tryParse(current['time'] ?? '') ?? DateTime.now(), ); } /// AQI to category description String get aqiCategory { if (aqi <= 50) return 'Good'; if (aqi <= 100) return 'Moderate'; if (aqi <= 150) return 'Unhealthy for Sensitive'; if (aqi <= 200) return 'Unhealthy'; if (aqi <= 300) return 'Very Unhealthy'; return 'Hazardous'; } /// Combined pollen level (max of all types) int get maxPollenLevel { return [grassPollen, birchPollen, olivePollen, ragweedPollen] .reduce((a, b) => a > b ? a : b); } /// Pollen category based on max level String get pollenCategory { final level = maxPollenLevel; if (level < 10) return 'Low'; if (level < 50) return 'Moderate'; if (level < 100) return 'High'; return 'Very High'; } } /// Sun and moon data for visibility planning class VisibilityData { final DateTime sunrise; final DateTime sunset; final double daylightDuration; // in hours final int moonPhase; // 0-7 (new moon to waning crescent) final DateTime timestamp; VisibilityData({ required this.sunrise, required this.sunset, required this.daylightDuration, required this.moonPhase, required this.timestamp, }); factory VisibilityData.fromJson(Map json) { final daily = json['daily'] as Map? ?? {}; final sunriseList = daily['sunrise'] as List? ?? []; final sunsetList = daily['sunset'] as List? ?? []; final daylightList = daily['daylight_duration'] as List? ?? []; return VisibilityData( sunrise: sunriseList.isNotEmpty ? DateTime.tryParse(sunriseList[0] ?? '') ?? DateTime.now() : DateTime.now(), sunset: sunsetList.isNotEmpty ? DateTime.tryParse(sunsetList[0] ?? '') ?? DateTime.now() : DateTime.now(), daylightDuration: daylightList.isNotEmpty ? ((daylightList[0] as num?)?.toDouble() ?? 0.0) / 3600 : 12.0, moonPhase: _calculateMoonPhase(), timestamp: DateTime.now(), ); } /// Calculate approximate moon phase (0-7) static int _calculateMoonPhase() { final now = DateTime.now(); // Known new moon: January 6, 2000 final knownNewMoon = DateTime(2000, 1, 6); final daysSinceNewMoon = now.difference(knownNewMoon).inDays; final lunarCycle = 29.53; // days final phase = (daysSinceNewMoon % lunarCycle) / lunarCycle; return (phase * 8).floor() % 8; } /// Moon phase name String get moonPhaseName { switch (moonPhase) { case 0: return 'New Moon'; case 1: return 'Waxing Crescent'; case 2: return 'First Quarter'; case 3: return 'Waxing Gibbous'; case 4: return 'Full Moon'; case 5: return 'Waning Gibbous'; case 6: return 'Last Quarter'; case 7: return 'Waning Crescent'; default: return 'Unknown'; } } /// Moon phase icon (using unicode) String get moonPhaseEmoji { switch (moonPhase) { case 0: return '🌑'; case 1: return '🌒'; case 2: return '🌓'; case 3: return '🌔'; case 4: return '🌕'; case 5: return '🌖'; case 6: return '🌗'; case 7: return '🌘'; default: return '🌑'; } } /// Whether it's currently daytime bool get isDaytime { final now = DateTime.now(); return now.isAfter(sunrise) && now.isBefore(sunset); } /// Time until sunrise/sunset Duration get timeUntilTransition { final now = DateTime.now(); if (isDaytime) { return sunset.difference(now); } else { if (now.isBefore(sunrise)) { return sunrise.difference(now); } else { // After sunset, calculate time to next sunrise (tomorrow) return sunrise.add(const Duration(days: 1)).difference(now); } } } } /// Public resource from OpenStreetMap Overpass API class PublicResource { final String id; final String name; final ResourceType type; final double latitude; final double longitude; final double? distanceMeters; final String? address; final String? phone; final String? website; final String? openingHours; PublicResource({ required this.id, required this.name, required this.type, required this.latitude, required this.longitude, this.distanceMeters, this.address, this.phone, this.website, this.openingHours, }); factory PublicResource.fromOverpassElement( Map element, double userLat, double userLng, ) { final tags = element['tags'] as Map? ?? {}; final lat = (element['lat'] as num?)?.toDouble() ?? (element['center']?['lat'] as num?)?.toDouble() ?? 0.0; final lng = (element['lon'] as num?)?.toDouble() ?? (element['center']?['lon'] as num?)?.toDouble() ?? 0.0; // Calculate distance final distance = _calculateDistance(userLat, userLng, lat, lng); // Determine type ResourceType type = ResourceType.other; final amenity = tags['amenity'] as String?; final leisure = tags['leisure'] as String?; if (amenity == 'library') { type = ResourceType.library; } else if (amenity == 'hospital') { type = ResourceType.hospital; } else if (amenity == 'police') { type = ResourceType.police; } else if (amenity == 'pharmacy') { type = ResourceType.pharmacy; } else if (amenity == 'fire_station') { type = ResourceType.fireStation; } else if (leisure == 'park') { type = ResourceType.park; } return PublicResource( id: element['id']?.toString() ?? '', name: tags['name'] as String? ?? type.displayName, type: type, latitude: lat, longitude: lng, distanceMeters: distance, address: _buildAddress(tags), phone: tags['phone'] as String?, website: tags['website'] as String?, openingHours: tags['opening_hours'] as String?, ); } static String? _buildAddress(Map tags) { final parts = []; if (tags['addr:housenumber'] != null) { parts.add(tags['addr:housenumber'].toString()); } if (tags['addr:street'] != null) { parts.add(tags['addr:street'].toString()); } if (tags['addr:city'] != null) { parts.add(tags['addr:city'].toString()); } return parts.isNotEmpty ? parts.join(', ') : null; } /// Haversine formula for distance calculation static double _calculateDistance( double lat1, double lon1, double lat2, double lon2, ) { const R = 6371000.0; // Earth's radius in meters final dLat = _toRadians(lat2 - lat1); final dLon = _toRadians(lon2 - lon1); final a = _sin(dLat / 2) * _sin(dLat / 2) + _cos(_toRadians(lat1)) * _cos(_toRadians(lat2)) * _sin(dLon / 2) * _sin(dLon / 2); final c = 2 * _atan2(_sqrt(a), _sqrt(1 - a)); return R * c; } static double _toRadians(double deg) => deg * 3.141592653589793 / 180; static double _sin(double x) => _taylorSin(x); static double _cos(double x) => _taylorSin(x + 1.5707963267948966); static double _sqrt(double x) { if (x <= 0) return 0; double guess = x / 2; for (int i = 0; i < 10; i++) { guess = (guess + x / guess) / 2; } return guess; } static double _atan2(double y, double x) { if (x > 0) return _atan(y / x); if (x < 0 && y >= 0) return _atan(y / x) + 3.141592653589793; if (x < 0 && y < 0) return _atan(y / x) - 3.141592653589793; if (x == 0 && y > 0) return 1.5707963267948966; if (x == 0 && y < 0) return -1.5707963267948966; return 0; } static double _atan(double x) { if (x.abs() > 1) { return x > 0 ? 1.5707963267948966 - _atan(1 / x) : -1.5707963267948966 - _atan(1 / x); } double result = 0; double term = x; for (int i = 1; i <= 15; i += 2) { result += term / i; term *= -x * x; } return result; } static double _taylorSin(double x) { // Normalize to [-pi, pi] while (x > 3.141592653589793) { x -= 6.283185307179586; } while (x < -3.141592653589793) { x += 6.283185307179586; } double result = 0; double term = x; for (int i = 1; i <= 15; i += 2) { result += term; term *= -x * x / ((i + 1) * (i + 2)); } return result; } /// Formatted distance string String get formattedDistance { if (distanceMeters == null) return ''; if (distanceMeters! < 1000) { return '${distanceMeters!.round()}m'; } return '${(distanceMeters! / 1000).toStringAsFixed(1)}km'; } } enum ResourceType { library, park, hospital, police, pharmacy, fireStation, other, } extension ResourceTypeExtension on ResourceType { String get displayName { switch (this) { case ResourceType.library: return 'Library'; case ResourceType.park: return 'Park'; case ResourceType.hospital: return 'Hospital'; case ResourceType.police: return 'Police Station'; case ResourceType.pharmacy: return 'Pharmacy'; case ResourceType.fireStation: return 'Fire Station'; case ResourceType.other: return 'Public Resource'; } } String get iconName { switch (this) { case ResourceType.library: return 'local_library'; case ResourceType.park: return 'park'; case ResourceType.hospital: return 'local_hospital'; case ResourceType.police: return 'local_police'; case ResourceType.pharmacy: return 'local_pharmacy'; case ResourceType.fireStation: return 'local_fire_department'; case ResourceType.other: return 'place'; } } } /// Combined local intel data class LocalIntelData { final WeatherConditions? weather; final EnvironmentalHazards? hazards; final VisibilityData? visibility; final List resources; final DateTime fetchedAt; final bool isLoading; final String? error; LocalIntelData({ this.weather, this.hazards, this.visibility, this.resources = const [], DateTime? fetchedAt, this.isLoading = false, this.error, }) : fetchedAt = fetchedAt ?? DateTime.now(); LocalIntelData copyWith({ WeatherConditions? weather, EnvironmentalHazards? hazards, VisibilityData? visibility, List? resources, DateTime? fetchedAt, bool? isLoading, String? error, }) { return LocalIntelData( weather: weather ?? this.weather, hazards: hazards ?? this.hazards, visibility: visibility ?? this.visibility, resources: resources ?? this.resources, fetchedAt: fetchedAt ?? this.fetchedAt, isLoading: isLoading ?? this.isLoading, error: error, ); } static LocalIntelData loading() { return LocalIntelData(isLoading: true); } static LocalIntelData withError(String error) { return LocalIntelData(error: error); } }