import 'dart:async'; import 'dart:math' as math; import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:firebase_auth/firebase_auth.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:flutter/foundation.dart'; import 'package:geolocator/geolocator.dart'; import 'road_alert_models.dart'; import 'swarm_road_alert_models.dart'; enum SwarmCloudStatus { unavailable, confirmed, pending, failed } class SwarmRoadAlertService { final FirebaseFirestore? _firestore; final FirebaseAuth? _auth; static int _idCounter = 0; // Local cache for offline resilience and immediate zero-latency feedback final Map _localSwarmCache = {}; SwarmRoadAlertService({ FirebaseFirestore? firestore, FirebaseAuth? auth, }) : _firestore = firestore ?? _resolveFirestore(), _auth = auth ?? _resolveAuth(); static FirebaseFirestore? _resolveFirestore() { try { if (Firebase.apps.isNotEmpty) return FirebaseFirestore.instance; } catch (_) {} return null; } static FirebaseAuth? _resolveAuth() { try { if (Firebase.apps.isNotEmpty) return FirebaseAuth.instance; } catch (_) {} return null; } bool get isCloudAvailable => _firestore != null; String? get _currentUid => _auth?.currentUser?.uid; /// Reports a new road alert (e.g. Police Speed Trap, Road Hazard, Accident). /// Saves to Firestore `/road_alerts` and caches locally. Future reportAlert({ required RoadAlertCategory category, required double latitude, required double longitude, double? heading, String? roadName, String? description, String? groupRideId, Duration? ttl, void Function(SwarmCloudStatus)? onCloudStatus, }) async { final now = DateTime.now(); final uid = _currentUid ?? 'local_rider'; final uidSuffix = uid.length >= 5 ? uid.substring(0, 5) : uid; final count = ++_idCounter; final alertId = 'swarm_${now.millisecondsSinceEpoch}_${count}_$uidSuffix'; final effectiveTtl = ttl ?? (category == RoadAlertCategory.police ? const Duration(minutes: 60) : const Duration(hours: 2)); final expiresAt = now.add(effectiveTtl); final desc = description ?? (category == RoadAlertCategory.police ? 'Police speed trap reported by rider' : (category == RoadAlertCategory.hazard ? 'Road hazard / debris reported' : (category == RoadAlertCategory.accident ? 'Traffic accident reported' : 'Rider safety report'))); final report = SwarmReport( id: alertId, category: category, latitude: latitude, longitude: longitude, heading: heading, roadName: roadName, description: desc, reporterUid: uid, reportedAt: now, expiresAt: expiresAt, confirmations: 1, dismissals: 0, groupRideId: groupRideId, ); // Cache locally immediately _localSwarmCache[alertId] = report; // Persist to Cloud Firestore if connected final firestore = _firestore; if (firestore != null) { try { await firestore .collection('road_alerts') .doc(alertId) .set(report.toJson()) .timeout(const Duration(seconds: 6)); onCloudStatus?.call(SwarmCloudStatus.confirmed); } on TimeoutException { // A timed-out Firestore write may still finish. Do not retry here: // this report already has a stable document ID. onCloudStatus?.call(SwarmCloudStatus.pending); } catch (e) { onCloudStatus?.call(SwarmCloudStatus.failed); if (kDebugMode) { debugPrint('[SwarmRoadAlertService] Firestore write failed (operating offline): $e'); } } } else { onCloudStatus?.call(SwarmCloudStatus.unavailable); } return report; } /// Trailing rider confirms an existing alert (increases confidence) Future confirmAlert(String alertId) async { final cached = _localSwarmCache[alertId]; if (cached != null) { _localSwarmCache[alertId] = cached.copyWith(confirmations: cached.confirmations + 1); } final firestore = _firestore; if (firestore != null) { try { await firestore.collection('road_alerts').doc(alertId).update({ 'confirmations': FieldValue.increment(1), }); } catch (e) { if (kDebugMode) { debugPrint('[SwarmRoadAlertService] Firestore confirm error: $e'); } } } } /// Trailing rider reports alert is clear / no longer present Future clearAlert(String alertId) async { final cached = _localSwarmCache[alertId]; if (cached != null) { final updatedDismissals = cached.dismissals + 1; if (updatedDismissals >= 2 && updatedDismissals >= cached.confirmations) { // Expire immediately _localSwarmCache.remove(alertId); } else { _localSwarmCache[alertId] = cached.copyWith(dismissals: updatedDismissals); } } final firestore = _firestore; if (firestore != null) { try { final docRef = firestore.collection('road_alerts').doc(alertId); final doc = await docRef.get(); if (doc.exists && doc.data() != null) { final data = doc.data()!; final dismissals = (data['dismissals'] as num?)?.toInt() ?? 0; final confirmations = (data['confirmations'] as num?)?.toInt() ?? 1; if (dismissals + 1 >= 2 && (dismissals + 1) >= confirmations) { // Mark expired await docRef.update({ 'dismissals': FieldValue.increment(1), 'expiresAt': DateTime.now().millisecondsSinceEpoch, }); } else { await docRef.update({ 'dismissals': FieldValue.increment(1), }); } } } catch (e) { if (kDebugMode) { debugPrint('[SwarmRoadAlertService] Firestore clear error: $e'); } } } } /// Queries all live alerts within [radiusMeters] of [latitude, longitude]. Future> getAlertsNear({ required double latitude, required double longitude, required double radiusMeters, }) async { final now = DateTime.now(); final Map candidates = {}; // 1. Ingest local in-memory cache for (var entry in _localSwarmCache.entries) { if (!entry.value.isExpired) { candidates[entry.key] = entry.value; } } // 2. Ingest from Firestore if connected final firestore = _firestore; if (firestore != null) { try { final latOffset = radiusMeters / 111320.0; final cosLat = math.cos(latitude * math.pi / 180.0); final lngOffset = radiusMeters / (111320.0 * (cosLat.abs() > 0.001 ? cosLat.abs() : 0.001)); final minLat = latitude - latOffset; final maxLat = latitude + latOffset; final minLng = longitude - lngOffset; final maxLng = longitude + lngOffset; final querySnap = await firestore .collection('road_alerts') .where('expiresAt', isGreaterThan: now.millisecondsSinceEpoch) .get(); for (var doc in querySnap.docs) { try { final report = SwarmReport.fromJson(doc.data()); // Client-side bounding box filter if (report.latitude >= minLat && report.latitude <= maxLat && report.longitude >= minLng && report.longitude <= maxLng) { candidates[report.id] = report; _localSwarmCache[report.id] = report; } } catch (_) {} } } catch (e) { if (kDebugMode) { debugPrint('[SwarmRoadAlertService] Query error: $e'); } } } // 3. Compute exact distances and convert to RoadAlert models final List results = []; for (var report in candidates.values) { if (report.isExpired) continue; final dist = Geolocator.distanceBetween( latitude, longitude, report.latitude, report.longitude, ); if (dist <= radiusMeters) { results.add(report.toRoadAlert(distanceFromUser: dist)); } } results.sort((a, b) => (a.distanceFromUser ?? 0).compareTo(b.distanceFromUser ?? 0)); return results; } /// Clears local in-memory cache (for testing or reset) void clearLocalCache() { _localSwarmCache.clear(); } }