sojorn/sojorn_app/lib/models/local_intel.dart
2026-02-15 00:33:24 -06:00

592 lines
15 KiB
Dart

// 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<String, dynamic> json) {
final current = json['current'] as Map<String, dynamic>? ?? {};
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<String, dynamic> json) {
final current = json['current'] as Map<String, dynamic>? ?? {};
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<String, dynamic> json) {
final daily = json['daily'] as Map<String, dynamic>? ?? {};
final sunriseList = daily['sunrise'] as List<dynamic>? ?? [];
final sunsetList = daily['sunset'] as List<dynamic>? ?? [];
final daylightList = daily['daylight_duration'] as List<dynamic>? ?? [];
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<String, dynamic> element,
double userLat,
double userLng,
) {
final tags = element['tags'] as Map<String, dynamic>? ?? {};
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<String, dynamic> tags) {
final parts = <String>[];
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<PublicResource> 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<PublicResource>? 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);
}
}