273 lines
8.4 KiB
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();
|
|
}
|
|
}
|