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

638 lines
21 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 RoadAlertRateLimitException implements Exception {
final int retryAfterSeconds;
RoadAlertRateLimitException(this.retryAfterSeconds);
@override
String toString() =>
'Please wait $retryAfterSeconds seconds before submitting another road alert.';
}
class SwarmRoadAlertService {
final FirebaseFirestore? _firestore;
final FirebaseAuth? _auth;
final Duration minReportInterval;
final double duplicateDistanceMeters;
final Duration duplicateWindow;
static int _idCounter = 0;
DateTime? _lastReportTime;
final Set<String> _userVotes = {}; // Key format: '$alertId:$uid'
// Local cache for offline resilience and immediate zero-latency feedback
final Map<String, SwarmReport> _localSwarmCache = {};
SwarmRoadAlertService({
FirebaseFirestore? firestore,
FirebaseAuth? auth,
this.minReportInterval = Duration.zero,
this.duplicateDistanceMeters = 200.0,
this.duplicateWindow = const Duration(minutes: 15),
}) : _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;
/// Returns a cached report by alert ID if available.
SwarmReport? getCachedReport(String alertId) => _localSwarmCache[alertId];
static Duration defaultTtlForCategory(RoadAlertCategory category) {
switch (category) {
case RoadAlertCategory.police:
return const Duration(minutes: 60);
case RoadAlertCategory.traffic:
return const Duration(minutes: 45);
case RoadAlertCategory.accident:
case RoadAlertCategory.roadClosure:
return const Duration(hours: 3);
case RoadAlertCategory.weatherHazard:
return const Duration(hours: 4);
case RoadAlertCategory.hazard:
case RoadAlertCategory.pothole:
case RoadAlertCategory.debris:
case RoadAlertCategory.disabledVehicle:
case RoadAlertCategory.construction:
case RoadAlertCategory.speedCamera:
case RoadAlertCategory.other:
return const Duration(hours: 2);
}
}
static String defaultDescriptionForCategory(RoadAlertCategory category) {
switch (category) {
case RoadAlertCategory.police:
return 'Police speed trap reported by rider';
case RoadAlertCategory.accident:
return 'Traffic accident reported';
case RoadAlertCategory.hazard:
return 'Road hazard reported';
case RoadAlertCategory.pothole:
return 'Severe pothole reported on road';
case RoadAlertCategory.debris:
return 'Road debris reported';
case RoadAlertCategory.disabledVehicle:
return 'Disabled vehicle reported on roadway';
case RoadAlertCategory.roadClosure:
return 'Road closure reported';
case RoadAlertCategory.construction:
return 'Road construction reported';
case RoadAlertCategory.traffic:
return 'Heavy traffic reported';
case RoadAlertCategory.weatherHazard:
return 'Adverse weather road hazard reported';
case RoadAlertCategory.speedCamera:
return 'Speed enforcement camera reported';
case RoadAlertCategory.other:
return 'Rider safety report';
}
}
bool _isCategoryMatch(RoadAlertCategory a, RoadAlertCategory b) {
if (a == b) return true;
final hazardGroup = {
RoadAlertCategory.hazard,
RoadAlertCategory.pothole,
RoadAlertCategory.debris,
RoadAlertCategory.disabledVehicle,
};
return hazardGroup.contains(a) && hazardGroup.contains(b);
}
SwarmReport? _findDuplicateAlert({
required RoadAlertCategory category,
required double latitude,
required double longitude,
required DateTime reportedAt,
}) {
for (final report in _localSwarmCache.values) {
if (report.isExpired) continue;
if (report.status == RoadAlertStatus.cleared ||
report.status == RoadAlertStatus.expired) {
continue;
}
final age = reportedAt.difference(report.reportedAt).abs();
if (age > duplicateWindow) continue;
if (!_isCategoryMatch(report.category, category)) continue;
final distance = Geolocator.distanceBetween(
latitude,
longitude,
report.latitude,
report.longitude,
);
if (distance <= duplicateDistanceMeters) {
return report;
}
}
return null;
}
/// Checks if the given user (or current user) has already voted on this alert.
bool hasUserVoted(String alertId, [String? voterUid]) {
final uid = voterUid ?? _currentUid ?? 'local_rider';
return _userVotes.contains('$alertId:$uid');
}
/// Checks if the given user is eligible to vote on this alert.
bool canUserVote(String alertId, [String? voterUid]) {
final uid = voterUid ?? _currentUid ?? 'local_rider';
final cached = _localSwarmCache[alertId];
if (cached != null && cached.reporterUid == uid) {
return false; // Reporter cannot vote on their own alert
}
return !_userVotes.contains('$alertId:$uid');
}
/// Reports a new road alert.
/// Saves to Firestore `/road_alerts` and caches locally.
/// Automatically checks for duplicate alerts nearby (< 200m within 15min)
/// and confirms existing alerts instead of duplicating.
Future<SwarmReport> reportAlert({
required RoadAlertCategory category,
required double latitude,
required double longitude,
String? subtype,
String? source,
double? heading,
String? roadName,
String? description,
String? groupRideId,
String? reporterUid,
Duration? ttl,
bool ignoreRateLimit = false,
void Function(SwarmCloudStatus)? onCloudStatus,
}) async {
final now = DateTime.now();
final uid = reporterUid ?? _currentUid ?? 'local_rider';
// Rate Limiting
if (!ignoreRateLimit && minReportInterval > Duration.zero && _lastReportTime != null) {
final elapsed = now.difference(_lastReportTime!);
if (elapsed < minReportInterval) {
final remaining = (minReportInterval - elapsed).inSeconds + 1;
throw RoadAlertRateLimitException(remaining);
}
}
// Duplicate detection and auto-merging
final duplicate = _findDuplicateAlert(
category: category,
latitude: latitude,
longitude: longitude,
reportedAt: now,
);
if (duplicate != null) {
if (kDebugMode) {
debugPrint('[SwarmRoadAlertService] Duplicate alert detected: ${duplicate.id}. Auto-confirming.');
}
if (duplicate.reporterUid != uid) {
await confirmAlert(duplicate.id, voterUid: uid);
return _localSwarmCache[duplicate.id] ?? duplicate;
}
return duplicate;
}
_lastReportTime = now;
final uidSuffix = uid.length >= 5 ? uid.substring(0, 5) : uid;
final count = ++_idCounter;
final alertId = 'swarm_${now.millisecondsSinceEpoch}_${count}_$uidSuffix';
final effectiveTtl = ttl ?? defaultTtlForCategory(category);
final expiresAt = now.add(effectiveTtl);
final desc = description ?? defaultDescriptionForCategory(category);
final effectiveSource = source ??
(groupRideId != null ? 'Pack Rider Report' : 'MotorSignal Swarm');
final report = SwarmReport(
id: alertId,
category: category,
subtype: subtype,
latitude: latitude,
longitude: longitude,
heading: heading,
roadName: roadName,
description: desc,
reporterUid: uid,
reportedAt: now,
expiresAt: expiresAt,
confirmations: 1,
dismissals: 0,
groupRideId: groupRideId,
source: effectiveSource,
status: RoadAlertStatus.active,
);
// 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).
/// Enforces per-user vote restrictions (no repeat votes, no self-voting).
/// Uses a Firestore transaction to authoritatively update confirmation count
/// and vote subcollection atomically across all devices.
Future<bool> confirmAlert(String alertId, {String? voterUid}) async {
final uid = voterUid ?? _currentUid ?? 'local_rider';
final cached = _localSwarmCache[alertId];
if (cached != null && cached.reporterUid == uid) {
if (kDebugMode) {
debugPrint('[SwarmRoadAlertService] Self-voting rejected for alert $alertId');
}
return false;
}
final voteKey = '$alertId:$uid';
if (_userVotes.contains(voteKey)) {
if (kDebugMode) {
debugPrint('[SwarmRoadAlertService] Repeat voting rejected for alert $alertId');
}
return false;
}
_userVotes.add(voteKey);
final previousCached = cached;
final now = DateTime.now();
// Optimistically update local cache
if (cached != null) {
final updatedConfirmations = cached.confirmations + 1;
final newStatus = updatedConfirmations >= 2
? RoadAlertStatus.confirmed
: cached.status;
_localSwarmCache[alertId] = cached.copyWith(
confirmations: updatedConfirmations,
status: newStatus,
);
}
final firestore = _firestore;
if (firestore != null) {
try {
final alertRef = firestore.collection('road_alerts').doc(alertId);
final voteRef = alertRef.collection('votes').doc(uid);
await firestore.runTransaction((transaction) async {
final snapshot = await transaction.get(alertRef);
if (!snapshot.exists) {
throw Exception('Alert does not exist');
}
final data = snapshot.data();
if (data == null) {
throw Exception('Alert data is null');
}
if (data['reporterUid'] == uid) {
throw Exception('Self-voting is not permitted');
}
final expiresAtMillis = (data['expiresAt'] as num?)?.toInt() ?? 0;
if (expiresAtMillis <= now.millisecondsSinceEpoch) {
throw Exception('Alert has already expired');
}
final statusStr = data['status']?.toString();
if (statusStr == 'cleared' || statusStr == 'expired') {
throw Exception('Alert is already $statusStr');
}
final currentConfirmations = (data['confirmations'] as num?)?.toInt() ?? 1;
final updatedConfirmations = currentConfirmations + 1;
final newStatus = updatedConfirmations >= 2 ? 'confirmed' : (statusStr ?? 'active');
transaction.set(voteRef, {
'voterUid': uid,
'type': 'confirm',
'votedAt': now.millisecondsSinceEpoch,
});
transaction.update(alertRef, {
'confirmations': updatedConfirmations,
'status': newStatus,
});
});
} catch (e) {
if (kDebugMode) {
debugPrint('[SwarmRoadAlertService] Firestore confirm error: $e');
}
// Rollback optimistic cache and vote record on failure
if (previousCached != null) {
_localSwarmCache[alertId] = previousCached;
} else {
_localSwarmCache.remove(alertId);
}
_userVotes.remove(voteKey);
return false;
}
}
return true;
}
/// Trailing rider reports alert is clear / no longer present.
/// Automatically marks alert cleared/expired when dismissals reach threshold.
/// Uses a Firestore transaction to authoritatively update dismissal count
/// and vote subcollection atomically across all devices.
Future<bool> clearAlert(String alertId, {String? voterUid}) async {
final uid = voterUid ?? _currentUid ?? 'local_rider';
final cached = _localSwarmCache[alertId];
if (cached != null && cached.reporterUid == uid) {
if (kDebugMode) {
debugPrint('[SwarmRoadAlertService] Self-clearing rejected for alert $alertId');
}
return false;
}
final voteKey = '$alertId:$uid';
if (_userVotes.contains(voteKey)) {
if (kDebugMode) {
debugPrint('[SwarmRoadAlertService] Repeat dismissal rejected for alert $alertId');
}
return false;
}
_userVotes.add(voteKey);
final previousCached = cached;
final now = DateTime.now();
// Optimistically update local cache
if (cached != null) {
final updatedDismissals = cached.dismissals + 1;
if (updatedDismissals >= 2 && updatedDismissals >= cached.confirmations) {
_localSwarmCache[alertId] = cached.copyWith(
dismissals: updatedDismissals,
expiresAt: now,
status: RoadAlertStatus.cleared,
);
} else {
_localSwarmCache[alertId] =
cached.copyWith(dismissals: updatedDismissals);
}
}
final firestore = _firestore;
if (firestore != null) {
try {
final alertRef = firestore.collection('road_alerts').doc(alertId);
final voteRef = alertRef.collection('votes').doc(uid);
await firestore.runTransaction((transaction) async {
final snapshot = await transaction.get(alertRef);
if (!snapshot.exists) {
throw Exception('Alert does not exist');
}
final data = snapshot.data();
if (data == null) {
throw Exception('Alert data is null');
}
if (data['reporterUid'] == uid) {
throw Exception('Self-clearing is not permitted');
}
final expiresAtMillis = (data['expiresAt'] as num?)?.toInt() ?? 0;
final nowMillis = now.millisecondsSinceEpoch;
if (expiresAtMillis <= nowMillis) {
throw Exception('Alert has already expired');
}
final statusStr = data['status']?.toString();
if (statusStr == 'cleared' || statusStr == 'expired') {
throw Exception('Alert is already $statusStr');
}
final currentConfirmations = (data['confirmations'] as num?)?.toInt() ?? 1;
final currentDismissals = (data['dismissals'] as num?)?.toInt() ?? 0;
final updatedDismissals = currentDismissals + 1;
final isCleared = updatedDismissals >= 2 && updatedDismissals >= currentConfirmations;
final newStatus = isCleared ? 'cleared' : (statusStr ?? 'active');
final newExpiresAt = isCleared ? nowMillis : expiresAtMillis;
transaction.set(voteRef, {
'voterUid': uid,
'type': 'dismiss',
'votedAt': nowMillis,
});
transaction.update(alertRef, {
'dismissals': updatedDismissals,
'status': newStatus,
'expiresAt': newExpiresAt,
});
});
} catch (e) {
if (kDebugMode) {
debugPrint('[SwarmRoadAlertService] Firestore clear error: $e');
}
// Rollback optimistic cache and vote record on failure
if (previousCached != null) {
_localSwarmCache[alertId] = previousCached;
} else {
_localSwarmCache.remove(alertId);
}
_userVotes.remove(voteKey);
return false;
}
}
return true;
}
/// Deletes alert if caller is the reporter.
Future<bool> deleteAlert(String alertId) async {
final uid = _currentUid ?? 'local_rider';
final cached = _localSwarmCache[alertId];
if (cached != null && cached.reporterUid != uid) {
return false;
}
_localSwarmCache.remove(alertId);
final firestore = _firestore;
if (firestore != null) {
try {
await firestore.collection('road_alerts').doc(alertId).delete();
} catch (e) {
if (kDebugMode) {
debugPrint('[SwarmRoadAlertService] Firestore delete error: $e');
}
}
}
return true;
}
/// 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 &&
entry.value.status != RoadAlertStatus.cleared &&
entry.value.status != RoadAlertStatus.expired) {
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());
// Filter out cleared/expired documents
if (report.status == RoadAlertStatus.cleared ||
report.status == RoadAlertStatus.expired ||
report.isExpired) {
continue;
}
// Honor local in-memory vote states and clearance
final cached = _localSwarmCache[report.id];
if (cached != null &&
(cached.status == RoadAlertStatus.cleared ||
cached.status == RoadAlertStatus.expired ||
cached.isExpired)) {
continue;
}
final effectiveReport = cached != null
? report.copyWith(
confirmations:
math.max(cached.confirmations, report.confirmations),
dismissals: math.max(cached.dismissals, report.dismissals),
status: cached.status == RoadAlertStatus.confirmed
? RoadAlertStatus.confirmed
: report.status,
)
: report;
// Client-side bounding box filter
if (effectiveReport.latitude >= minLat &&
effectiveReport.latitude <= maxLat &&
effectiveReport.longitude >= minLng &&
effectiveReport.longitude <= maxLng) {
candidates[effectiveReport.id] = effectiveReport;
_localSwarmCache[effectiveReport.id] = effectiveReport;
}
} 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;
if (report.status == RoadAlertStatus.cleared ||
report.status == RoadAlertStatus.expired) {
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 and voting records (for testing or reset)
void clearLocalCache() {
_localSwarmCache.clear();
_userVotes.clear();
_lastReportTime = null;
}
}