638 lines
21 KiB
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;
|
|
}
|
|
}
|