Pre-Production-Hardening

This commit is contained in:
Developer 2026-08-27 03:49:54 -04:00
parent 930b7a9f55
commit 3e9f8eb8e7
7 changed files with 664 additions and 32 deletions

View File

@ -17,17 +17,21 @@ class SettingsScreen extends ConsumerStatefulWidget {
class _SettingsScreenState extends ConsumerState<SettingsScreen> {
late TextEditingController _tomtomKeyController;
bool _isTestingKey = false;
late TextEditingController _communityKeyController;
bool _isTestingTomTom = false;
bool _isTestingCommunity = false;
@override
void initState() {
super.initState();
_tomtomKeyController = TextEditingController(text: ref.read(tomtomApiKeyProvider));
_communityKeyController = TextEditingController(text: ref.read(communityApiKeyProvider));
}
@override
void dispose() {
_tomtomKeyController.dispose();
_communityKeyController.dispose();
super.dispose();
}
@ -37,7 +41,9 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
final roadAlertsEnabled = ref.watch(roadAlertsEnabledProvider);
final audioAlertsEnabled = ref.watch(audioAlertsEnabledProvider);
final alertRadiusMeters = ref.watch(alertRadiusMetersProvider);
final enabledCategories = ref.watch(enabledCategoriesProvider);
final tomtomStatus = ref.watch(tomtomStatusProvider);
final communityStatus = ref.watch(communityStatusProvider);
return Scaffold(
appBar: AppBar(title: const Text('Settings')),
@ -109,11 +115,12 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SwitchListTile(
activeColor: Colors.cyanAccent,
activeThumbColor: Colors.cyanAccent,
title: const Text('Road Alerts', style: TextStyle(fontWeight: FontWeight.bold)),
subtitle: const Text('Display real-time traffic incidents, hazards, and road closures on map.'),
subtitle: const Text('Display real-time police, hazards, and traffic incidents on map.'),
value: roadAlertsEnabled,
onChanged: (val) {
ref.read(roadAlertsEnabledProvider.notifier).setEnabled(val);
@ -121,9 +128,9 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
),
const Divider(),
SwitchListTile(
activeColor: Colors.cyanAccent,
activeThumbColor: Colors.cyanAccent,
title: const Text('Audio Warnings (TTS)', style: TextStyle(fontWeight: FontWeight.bold)),
subtitle: const Text('Announce approaching hazards over helmet headset.'),
subtitle: const Text('Announce approaching hazards and enforcement over helmet headset.'),
value: audioAlertsEnabled,
onChanged: roadAlertsEnabled
? (val) {
@ -165,6 +172,97 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
],
),
),
const Divider(),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: Text('Alert Categories', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
),
...RoadAlertCategory.values.map((cat) {
final isChecked = enabledCategories.contains(cat);
return CheckboxListTile(
activeColor: Colors.cyanAccent,
checkColor: Colors.black,
title: Text(cat.displayName),
secondary: Icon(cat.icon, color: cat.color),
value: isChecked,
onChanged: roadAlertsEnabled
? (val) {
ref
.read(enabledCategoriesProvider.notifier)
.toggleCategory(cat, val ?? true);
}
: null,
);
}),
],
),
),
),
const SizedBox(height: 16),
// Community Traffic Provider Card
Card(
clipBehavior: Clip.antiAlias,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.local_police, color: Colors.blueAccent),
const SizedBox(width: 12),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Community Traffic Provider', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
SizedBox(height: 4),
Text('Supplies Police, Speed Cameras, and Crowd Hazards.', style: TextStyle(fontSize: 12, color: Colors.white70)),
],
),
),
_buildStatusBadge(communityStatus),
],
),
const SizedBox(height: 16),
TextField(
controller: _communityKeyController,
decoration: const InputDecoration(
labelText: 'Community Provider API Key',
hintText: 'Enter API Key for Community Traffic Feed',
border: OutlineInputBorder(),
),
onChanged: (val) {
ref.read(communityApiKeyProvider.notifier).setApiKey(val);
},
),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerRight,
child: ElevatedButton.icon(
onPressed: _isTestingCommunity
? null
: () async {
setState(() => _isTestingCommunity = true);
final provider = ref.read(communityProviderInstance);
final status = await provider.testConnection(_communityKeyController.text);
ref.read(communityStatusProvider.notifier).state = status;
setState(() => _isTestingCommunity = false);
if (mounted) {
String msg = 'Community API Connected successfully!';
if (status == RoadAlertProviderStatus.authError) msg = 'Authentication Error: Invalid Key.';
if (status == RoadAlertProviderStatus.notConfigured) msg = 'Key is empty.';
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
}
},
icon: _isTestingCommunity
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.sync),
label: const Text('Test Connection'),
),
),
],
),
),
@ -212,23 +310,23 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
Align(
alignment: Alignment.centerRight,
child: ElevatedButton.icon(
onPressed: _isTestingKey
onPressed: _isTestingTomTom
? null
: () async {
setState(() => _isTestingKey = true);
setState(() => _isTestingTomTom = true);
final provider = ref.read(tomtomProviderInstance);
final status = await provider.testConnection(_tomtomKeyController.text);
ref.read(tomtomStatusProvider.notifier).state = status;
setState(() => _isTestingKey = false);
setState(() => _isTestingTomTom = false);
if (mounted) {
String msg = 'Connected successfully!';
if (status == RoadAlertProviderStatus.authError) msg = 'Authentication Error: Invalid API Key.';
String msg = 'TomTom API Connected successfully!';
if (status == RoadAlertProviderStatus.authError) msg = 'Authentication Error: Invalid Key.';
if (status == RoadAlertProviderStatus.notConfigured) msg = 'Key is empty.';
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
}
},
icon: _isTestingKey
icon: _isTestingTomTom
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.sync),
label: const Text('Test Connection'),
@ -404,7 +502,6 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
color = Colors.amberAccent;
break;
case RoadAlertProviderStatus.notConfigured:
default:
label = 'Not Set';
color = Colors.white38;
break;

View File

@ -0,0 +1,135 @@
import 'road_alert_models.dart';
class CommunityTrafficParser {
static List<RoadAlert> parseAlertsJson(
Map<String, dynamic> json, {
required String providerId,
required String providerName,
}) {
final List<RoadAlert> alerts = [];
final rawAlerts = json['alerts'] ?? json['incidents'] ?? [];
if (rawAlerts is! List) return alerts;
for (var item in rawAlerts) {
if (item is! Map<String, dynamic>) continue;
final id = item['id']?.toString() ?? item['uuid']?.toString() ?? '${item['latitude']}_${item['longitude']}';
final typeStr = (item['type'] ?? item['category'] ?? item['typeGroup'] ?? '').toString().toUpperCase();
final subTypeStr = (item['subtype'] ?? item['subCategory'] ?? '').toString().toUpperCase();
final category = mapTypeToCategory(typeStr, subTypeStr);
final double? lat = _toDouble(item['latitude'] ?? item['lat'] ?? (item['location'] != null ? item['location']['y'] : null));
final double? lng = _toDouble(item['longitude'] ?? item['lon'] ?? item['lng'] ?? (item['location'] != null ? item['location']['x'] : null));
if (lat == null || lng == null) continue;
final double? heading = _toDouble(item['heading'] ?? item['bearing'] ?? item['direction']);
final String? street = item['street']?.toString() ?? item['road']?.toString() ?? item['locationName']?.toString();
final String description = _buildDescription(typeStr, subTypeStr, item['reportDescription'] ?? item['description']);
final now = DateTime.now();
final int nMinutesOld = (item['pubMillis'] != null)
? ((now.millisecondsSinceEpoch - (item['pubMillis'] as num).toInt()) / 60000).clamp(0, 120).toInt()
: 5;
final reportedAt = now.subtract(Duration(minutes: nMinutesOld));
final expiresAt = now.add(const Duration(minutes: 30));
final double? confidence = _toDouble(item['confidence'] ?? item['reliability']);
final severity = _mapSeverity(item['severity'] ?? item['reliability']);
alerts.add(RoadAlert(
id: id,
providerId: providerId,
category: category,
latitude: lat,
longitude: lng,
heading: heading,
roadName: street,
description: description,
reportedAt: reportedAt,
expiresAt: expiresAt,
lastUpdated: now,
confidence: confidence != null ? (confidence > 1.0 ? confidence / 10.0 : confidence) : 0.8,
severity: severity,
source: providerName,
sourceType: RoadAlertSourceType.userReported,
));
}
return alerts;
}
static RoadAlertCategory mapTypeToCategory(String type, String subtype) {
final combined = '${type}_$subtype';
if (combined.contains('POLICE') ||
type == 'POLICE' ||
subtype.contains('POLICE_VISIBLE') ||
subtype.contains('POLICE_HIDDEN') ||
subtype.contains('POLICE_TRAP')) {
return RoadAlertCategory.police;
}
if (combined.contains('SPEED_CAM') ||
combined.contains('CAMERA') ||
subtype.contains('SPEED_CAMERA') ||
subtype.contains('RED_LIGHT_CAMERA')) {
return RoadAlertCategory.speedCamera;
}
if (combined.contains('ACCIDENT') || type == 'ACCIDENT') {
return RoadAlertCategory.accident;
}
if (combined.contains('HAZARD') || type == 'HAZARD' || subtype.contains('DEBRIS') || subtype.contains('WEATHER')) {
return RoadAlertCategory.hazard;
}
if (combined.contains('ROAD_CLOSED') || combined.contains('CLOSURE') || type == 'ROAD_CLOSED') {
return RoadAlertCategory.roadClosure;
}
if (combined.contains('CONSTRUCTION') || combined.contains('ROAD_WORKS') || type == 'CONSTRUCTION') {
return RoadAlertCategory.construction;
}
if (combined.contains('JAM') || combined.contains('TRAFFIC') || type == 'JAM') {
return RoadAlertCategory.traffic;
}
return RoadAlertCategory.other;
}
static String _buildDescription(String type, String subtype, dynamic rawDesc) {
if (rawDesc != null && rawDesc.toString().trim().isNotEmpty) {
return rawDesc.toString();
}
if (subtype.contains('POLICE_HIDDEN')) return 'Police reported hidden on shoulder';
if (subtype.contains('POLICE_VISIBLE')) return 'Police reported visible ahead';
if (type == 'POLICE') return 'Police report in area';
if (subtype.contains('SPEED_CAMERA')) return 'Speed enforcement camera reported';
if (type == 'ACCIDENT') return 'Traffic accident reported';
if (type == 'HAZARD') return 'Road hazard reported';
if (type == 'ROAD_CLOSED') return 'Road closed';
if (type == 'CONSTRUCTION') return 'Construction zone';
return 'Community traffic report';
}
static RoadAlertSeverity _mapSeverity(dynamic val) {
if (val == null) return RoadAlertSeverity.medium;
final num n = (val is num) ? val : num.tryParse(val.toString()) ?? 5;
if (n >= 8) return RoadAlertSeverity.critical;
if (n >= 5) return RoadAlertSeverity.high;
if (n >= 3) return RoadAlertSeverity.medium;
return RoadAlertSeverity.low;
}
static double? _toDouble(dynamic val) {
if (val == null) return null;
if (val is num) return val.toDouble();
return double.tryParse(val.toString());
}
}

View File

@ -0,0 +1,100 @@
import 'dart:convert';
import 'dart:math' as math;
import 'package:http/http.dart' as http;
import 'road_alert_models.dart';
import 'road_alert_provider_interface.dart';
import 'community_traffic_parser.dart';
class CommunityTrafficRoadAlertProvider implements RoadAlertProvider {
final String _customEndpointUrl;
CommunityTrafficRoadAlertProvider({String? endpointUrl})
: _customEndpointUrl = endpointUrl ?? '';
@override
String get id => 'community_traffic';
@override
String get name => 'Community Traffic API';
@override
Set<RoadAlertCategory> get supportedCategories => const {
RoadAlertCategory.police,
RoadAlertCategory.speedCamera,
RoadAlertCategory.accident,
RoadAlertCategory.hazard,
RoadAlertCategory.roadClosure,
RoadAlertCategory.construction,
RoadAlertCategory.traffic,
RoadAlertCategory.other,
};
@override
Future<RoadAlertProviderStatus> testConnection(String apiKey) async {
if (apiKey.trim().isEmpty && _customEndpointUrl.isEmpty) {
return RoadAlertProviderStatus.notConfigured;
}
try {
final endpoint = _customEndpointUrl.isNotEmpty
? _customEndpointUrl
: 'https://api.communitytraffic.org/v1/incidents';
final uri = Uri.parse('$endpoint?top=37.80&bottom=37.70&left=-122.50&right=-122.35&apiKey=$apiKey');
final response = await http.get(uri).timeout(const Duration(seconds: 6));
if (response.statusCode == 200) {
return RoadAlertProviderStatus.connected;
} else if (response.statusCode == 401 || response.statusCode == 403) {
return RoadAlertProviderStatus.authError;
} else if (response.statusCode == 429) {
return RoadAlertProviderStatus.rateLimited;
}
return RoadAlertProviderStatus.networkError;
} catch (_) {
return RoadAlertProviderStatus.networkError;
}
}
@override
Future<List<RoadAlert>> getAlerts({
required double latitude,
required double longitude,
required double radiusMeters,
String? apiKey,
}) async {
if ((apiKey == null || apiKey.trim().isEmpty) && _customEndpointUrl.isEmpty) {
return [];
}
try {
final latOffset = radiusMeters / 111000.0;
final lngOffset = radiusMeters / (111000.0 * math.cos(latitude * math.pi / 180.0));
final top = (latitude + latOffset).toStringAsFixed(5);
final bottom = (latitude - latOffset).toStringAsFixed(5);
final left = (longitude - lngOffset).toStringAsFixed(5);
final right = (longitude + lngOffset).toStringAsFixed(5);
final endpoint = _customEndpointUrl.isNotEmpty
? _customEndpointUrl
: 'https://api.communitytraffic.org/v1/incidents';
final uri = Uri.parse('$endpoint?top=$top&bottom=$bottom&left=$left&right=$right&apiKey=${apiKey ?? ''}');
final response = await http.get(uri).timeout(const Duration(seconds: 8));
if (response.statusCode != 200) {
return [];
}
final Map<String, dynamic> jsonMap = json.decode(response.body);
return CommunityTrafficParser.parseAlertsJson(
jsonMap,
providerId: id,
providerName: name,
);
} catch (_) {
return [];
}
}
}

View File

@ -0,0 +1,95 @@
import 'package:flutter/foundation.dart';
import 'road_alert_models.dart';
import 'road_alert_provider_interface.dart';
class MockRoadAlertProvider implements RoadAlertProvider {
@override
String get id => 'mock_police_debug';
@override
String get name => 'Debug Mock Police & Speed-Traps';
@override
Set<RoadAlertCategory> get supportedCategories => const {
RoadAlertCategory.police,
RoadAlertCategory.speedCamera,
RoadAlertCategory.accident,
RoadAlertCategory.hazard,
RoadAlertCategory.roadClosure,
RoadAlertCategory.construction,
RoadAlertCategory.traffic,
RoadAlertCategory.other,
};
@override
Future<RoadAlertProviderStatus> testConnection(String apiKey) async {
return kDebugMode ? RoadAlertProviderStatus.connected : RoadAlertProviderStatus.notConfigured;
}
@override
Future<List<RoadAlert>> getAlerts({
required double latitude,
required double longitude,
required double radiusMeters,
String? apiKey,
}) async {
// Safety check: Never return mock alerts in release mode
if (!kDebugMode) return [];
final now = DateTime.now();
return [
RoadAlert(
id: 'mock_police_1',
providerId: id,
category: RoadAlertCategory.police,
latitude: latitude + 0.0035, // ~380 meters ahead
longitude: longitude + 0.0012,
heading: 45.0,
roadName: 'Highway 101 North / Mile Marker 14',
description: 'Police reported visible on shoulder (Debug Mock)',
reportedAt: now.subtract(const Duration(minutes: 4)),
expiresAt: now.add(const Duration(minutes: 26)),
lastUpdated: now,
confidence: 0.88,
severity: RoadAlertSeverity.high,
source: 'Community Report (Debug Mock)',
sourceType: RoadAlertSourceType.userReported,
),
RoadAlert(
id: 'mock_speedcam_1',
providerId: id,
category: RoadAlertCategory.speedCamera,
latitude: latitude + 0.0055, // ~600 meters ahead
longitude: longitude - 0.0020,
heading: 90.0,
roadName: 'Grand Avenue & 5th St',
description: 'Speed enforcement camera active (Debug Mock)',
reportedAt: now.subtract(const Duration(minutes: 15)),
expiresAt: now.add(const Duration(hours: 1)),
lastUpdated: now,
confidence: 0.95,
severity: RoadAlertSeverity.high,
source: 'Camera Database (Debug Mock)',
sourceType: RoadAlertSourceType.providerReported,
),
RoadAlert(
id: 'mock_accident_1',
providerId: id,
category: RoadAlertCategory.accident,
latitude: latitude - 0.0040,
longitude: longitude - 0.0030,
heading: 180.0,
roadName: 'Bay Bridge Connector',
description: 'Two-car collision right lane (Debug Mock)',
reportedAt: now.subtract(const Duration(minutes: 8)),
expiresAt: now.add(const Duration(minutes: 40)),
lastUpdated: now,
confidence: 0.92,
severity: RoadAlertSeverity.critical,
source: 'Community Report (Debug Mock)',
sourceType: RoadAlertSourceType.userReported,
),
];
}
}

View File

@ -1,16 +1,22 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'road_alert_models.dart';
import 'road_alert_provider_interface.dart';
import 'tomtom_road_alert_provider.dart';
import 'local_road_alert_provider.dart';
import 'community_traffic_provider.dart';
import 'mock_road_alert_provider.dart';
final tomtomProviderInstance = Provider<TomTomRoadAlertProvider>((ref) {
return TomTomRoadAlertProvider();
});
final localProviderInstance = Provider<LocalRoadAlertProvider>((ref) {
return LocalRoadAlertProvider();
final communityProviderInstance = Provider<CommunityTrafficRoadAlertProvider>((ref) {
return CommunityTrafficRoadAlertProvider();
});
final mockProviderInstance = Provider<MockRoadAlertProvider>((ref) {
return MockRoadAlertProvider();
});
// Master Enable
@ -55,7 +61,7 @@ final audioAlertsEnabledProvider =
return AudioAlertsEnabledNotifier();
});
// Alert Radius in Meters (500ft = 152.4m, 1000ft = 304.8m, 0.25mi = 402.3m, 0.5mi = 804.7m, 1mi = 1609.3m)
// Alert Radius in Meters
class AlertRadiusMetersNotifier extends StateNotifier<double> {
static const _key = 'alert_radius_meters';
AlertRadiusMetersNotifier() : super(804.7) {
@ -76,6 +82,35 @@ final alertRadiusMetersProvider =
return AlertRadiusMetersNotifier();
});
// Category Filtering Notifier
class EnabledCategoriesNotifier extends StateNotifier<Set<RoadAlertCategory>> {
EnabledCategoriesNotifier()
: super({
RoadAlertCategory.police,
RoadAlertCategory.speedCamera,
RoadAlertCategory.accident,
RoadAlertCategory.hazard,
RoadAlertCategory.roadClosure,
RoadAlertCategory.construction,
RoadAlertCategory.traffic,
RoadAlertCategory.other,
});
void toggleCategory(RoadAlertCategory category, bool enabled) {
final current = Set<RoadAlertCategory>.from(state);
if (enabled) {
current.add(category);
} else {
current.remove(category);
}
state = current;
}
}
final enabledCategoriesProvider =
StateNotifierProvider<EnabledCategoriesNotifier, Set<RoadAlertCategory>>((ref) {
return EnabledCategoriesNotifier();
});
// TomTom API Key
class TomTomApiKeyNotifier extends StateNotifier<String> {
static const _key = 'tomtom_api_key';
@ -97,23 +132,54 @@ final tomtomApiKeyProvider =
return TomTomApiKeyNotifier();
});
// TomTom Connection Status
// Community Traffic API Key
class CommunityApiKeyNotifier extends StateNotifier<String> {
static const _key = 'community_api_key';
CommunityApiKeyNotifier() : super('') {
_load();
}
Future<void> _load() async {
final prefs = await SharedPreferences.getInstance();
state = prefs.getString(_key) ?? '';
}
Future<void> setApiKey(String apiKey) async {
state = apiKey.trim();
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_key, state);
}
}
final communityApiKeyProvider =
StateNotifierProvider<CommunityApiKeyNotifier, String>((ref) {
return CommunityApiKeyNotifier();
});
final tomtomStatusProvider = StateProvider<RoadAlertProviderStatus>((ref) {
final key = ref.watch(tomtomApiKeyProvider);
if (key.isEmpty) {
return RoadAlertProviderStatus.notConfigured;
}
return RoadAlertProviderStatus.configured;
return key.isEmpty ? RoadAlertProviderStatus.notConfigured : RoadAlertProviderStatus.configured;
});
final communityStatusProvider = StateProvider<RoadAlertProviderStatus>((ref) {
final key = ref.watch(communityApiKeyProvider);
return key.isEmpty ? RoadAlertProviderStatus.notConfigured : RoadAlertProviderStatus.configured;
});
// Active Providers List
final activeRoadAlertProviders = Provider<List<RoadAlertProvider>>((ref) {
final tomtomKey = ref.watch(tomtomApiKeyProvider);
final communityKey = ref.watch(communityApiKeyProvider);
final list = <RoadAlertProvider>[];
if (tomtomKey.isNotEmpty) {
list.add(ref.watch(tomtomProviderInstance));
}
// Include demo local provider as fallback/demo
list.add(ref.watch(localProviderInstance));
if (communityKey.isNotEmpty) {
list.add(ref.watch(communityProviderInstance));
}
// Include Debug Mock Provider only in debug mode
if (kDebugMode) {
list.add(ref.watch(mockProviderInstance));
}
return list;
});

View File

@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_tts/flutter_tts.dart';
import 'package:geolocator/geolocator.dart';
@ -58,9 +59,11 @@ class RoadAlertService {
final now = DateTime.now();
final radiusMeters = _ref.read(alertRadiusMetersProvider);
final audioEnabled = _ref.read(audioAlertsEnabledProvider);
final enabledCategories = _ref.read(enabledCategoriesProvider);
final tomtomApiKey = _ref.read(tomtomApiKeyProvider);
final communityApiKey = _ref.read(communityApiKeyProvider);
// Throttle fetches: fetch if 45s passed OR moved > 500m
// Throttle fetches: query every 45s OR when moved > 500m
bool shouldFetch = false;
if (_lastFetchTime == null || now.difference(_lastFetchTime!).inSeconds >= 45) {
shouldFetch = true;
@ -79,16 +82,27 @@ class RoadAlertService {
if (shouldFetch) {
_lastFetchTime = now;
_lastFetchPosition = position;
await _fetchAlertsFromProviders(position.latitude, position.longitude, radiusMeters, tomtomApiKey);
await _fetchAlertsFromProviders(
position.latitude,
position.longitude,
radiusMeters,
tomtomApiKey: tomtomApiKey,
communityApiKey: communityApiKey,
);
}
// Update distance for cached alerts & filter out expired ones
final Map<String, RoadAlert> deduplicated = {};
for (var alert in _cachedAlerts) {
// Expiration check
if (alert.expiresAt != null && alert.expiresAt!.isBefore(now)) {
continue;
}
// Category toggle check
if (!enabledCategories.contains(alert.category)) {
continue;
}
final dist = Geolocator.distanceBetween(
position.latitude,
position.longitude,
@ -101,10 +115,13 @@ class RoadAlertService {
final key = '${alert.providerId}_${alert.id}';
deduplicated[key] = updatedAlert;
// Check audio alert approach (< 400m)
// Approach Audio Warning: check distance (< 400m), cooldown, and direction relevance
if (audioEnabled && dist <= 400.0 && !_announcedAlertIds.contains(key)) {
_announcedAlertIds.add(key);
_announceAlertAudio(updatedAlert);
final isAhead = _isAlertAheadOfRider(position, alert);
if (isAhead) {
_announcedAlertIds.add(key);
_announceAlertAudio(updatedAlert);
}
}
}
}
@ -116,18 +133,46 @@ class RoadAlertService {
_alertsController.add(resultList);
}
bool _isAlertAheadOfRider(Position userPos, RoadAlert alert) {
// If rider speed < 2.0 m/s (standing still), assume alert is relevant
if (userPos.speed < 2.0) return true;
final riderHeading = userPos.heading;
final bearingToAlert = Geolocator.bearingBetween(
userPos.latitude,
userPos.longitude,
alert.latitude,
alert.longitude,
);
double delta = (bearingToAlert - riderHeading).abs();
if (delta > 180) delta = 360 - delta;
// Relevant if alert is within ±100 degrees of rider's heading (in front or side)
return delta <= 100.0;
}
Future<void> _fetchAlertsFromProviders(
double lat, double lng, double radiusMeters, String tomtomApiKey) async {
double lat,
double lng,
double radiusMeters, {
required String tomtomApiKey,
required String communityApiKey,
}) async {
final providers = _ref.read(activeRoadAlertProviders);
final List<RoadAlert> newAlerts = [];
for (var provider in providers) {
try {
String? key;
if (provider.id == 'tomtom') key = tomtomApiKey;
if (provider.id == 'community_traffic') key = communityApiKey;
final fetched = await provider.getAlerts(
latitude: lat,
longitude: lng,
radiusMeters: radiusMeters,
apiKey: provider.id == 'tomtom' ? tomtomApiKey : null,
apiKey: key,
);
newAlerts.addAll(fetched);
} catch (_) {}
@ -140,7 +185,7 @@ class RoadAlertService {
String phrase;
switch (alert.category) {
case RoadAlertCategory.police:
phrase = "Police reported ahead.";
phrase = "Police report ahead.";
break;
case RoadAlertCategory.speedCamera:
phrase = "Speed camera reported ahead.";

View File

@ -0,0 +1,94 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:motorcycle_dash/services/alerts/road_alert_models.dart';
import 'package:motorcycle_dash/services/alerts/community_traffic_parser.dart';
import 'package:motorcycle_dash/services/alerts/community_traffic_provider.dart';
import 'package:motorcycle_dash/services/alerts/mock_road_alert_provider.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('Police & Speed-Trap Provider Tests', () {
test('CommunityTrafficParser maps POLICE and POLICE_VISIBLE to police category', () {
final jsonSample = {
'alerts': [
{
'id': 'police_101',
'type': 'POLICE',
'subtype': 'POLICE_VISIBLE',
'latitude': 37.7750,
'longitude': -122.4190,
'street': 'US-101 N',
'reportDescription': 'Officer visible on shoulder',
'confidence': 9,
},
{
'id': 'speedcam_202',
'type': 'SPEED_CAM',
'subtype': 'RED_LIGHT_CAMERA',
'latitude': 37.7800,
'longitude': -122.4200,
'street': 'Market St',
'reportDescription': 'Fixed speed camera',
'confidence': 10,
},
{
'id': 'hazard_303',
'type': 'HAZARD',
'subtype': 'DEBRIS_ON_ROAD',
'latitude': 37.7700,
'longitude': -122.4150,
'street': 'Mission St',
'reportDescription': 'Tire debris in lane',
'confidence': 8,
}
]
};
final alerts = CommunityTrafficParser.parseAlertsJson(
jsonSample,
providerId: 'community_test',
providerName: 'Community Test API',
);
expect(alerts.length, equals(3));
final policeAlert = alerts.firstWhere((a) => a.id == 'police_101');
expect(policeAlert.category, equals(RoadAlertCategory.police));
expect(policeAlert.roadName, equals('US-101 N'));
expect(policeAlert.source, equals('Community Test API'));
expect(policeAlert.sourceType, equals(RoadAlertSourceType.userReported));
final cameraAlert = alerts.firstWhere((a) => a.id == 'speedcam_202');
expect(cameraAlert.category, equals(RoadAlertCategory.speedCamera));
final hazardAlert = alerts.firstWhere((a) => a.id == 'hazard_303');
expect(hazardAlert.category, equals(RoadAlertCategory.hazard));
});
test('CommunityTrafficRoadAlertProvider advertises police and speed camera support', () {
final provider = CommunityTrafficRoadAlertProvider();
expect(provider.id, equals('community_traffic'));
expect(provider.supportedCategories, contains(RoadAlertCategory.police));
expect(provider.supportedCategories, contains(RoadAlertCategory.speedCamera));
expect(provider.supportedCategories, contains(RoadAlertCategory.accident));
});
test('MockRoadAlertProvider debug guard', () async {
final mockProvider = MockRoadAlertProvider();
final alerts = await mockProvider.getAlerts(
latitude: 37.7749,
longitude: -122.4194,
radiusMeters: 1000.0,
);
if (kDebugMode) {
expect(alerts.isNotEmpty, isTrue);
expect(alerts.any((a) => a.category == RoadAlertCategory.police), isTrue);
expect(alerts.any((a) => a.category == RoadAlertCategory.speedCamera), isTrue);
} else {
expect(alerts.isEmpty, isTrue);
}
});
});
}