MotorSignal-App/lib/services/alerts/swarm_road_alert_service.dart

273 lines
8.4 KiB
Dart

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<String, SwarmReport> _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<SwarmReport> 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<void> 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<void> 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<List<RoadAlert>> getAlertsNear({
required double latitude,
required double longitude,
required double radiusMeters,
}) async {
final now = DateTime.now();
final Map<String, SwarmReport> 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<RoadAlert> 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();
}
}