Pre-Production-Hardening
This commit is contained in:
parent
930b7a9f55
commit
3e9f8eb8e7
|
|
@ -17,17 +17,21 @@ class SettingsScreen extends ConsumerStatefulWidget {
|
||||||
|
|
||||||
class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||||
late TextEditingController _tomtomKeyController;
|
late TextEditingController _tomtomKeyController;
|
||||||
bool _isTestingKey = false;
|
late TextEditingController _communityKeyController;
|
||||||
|
bool _isTestingTomTom = false;
|
||||||
|
bool _isTestingCommunity = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_tomtomKeyController = TextEditingController(text: ref.read(tomtomApiKeyProvider));
|
_tomtomKeyController = TextEditingController(text: ref.read(tomtomApiKeyProvider));
|
||||||
|
_communityKeyController = TextEditingController(text: ref.read(communityApiKeyProvider));
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_tomtomKeyController.dispose();
|
_tomtomKeyController.dispose();
|
||||||
|
_communityKeyController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -37,7 +41,9 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||||
final roadAlertsEnabled = ref.watch(roadAlertsEnabledProvider);
|
final roadAlertsEnabled = ref.watch(roadAlertsEnabledProvider);
|
||||||
final audioAlertsEnabled = ref.watch(audioAlertsEnabledProvider);
|
final audioAlertsEnabled = ref.watch(audioAlertsEnabledProvider);
|
||||||
final alertRadiusMeters = ref.watch(alertRadiusMetersProvider);
|
final alertRadiusMeters = ref.watch(alertRadiusMetersProvider);
|
||||||
|
final enabledCategories = ref.watch(enabledCategoriesProvider);
|
||||||
final tomtomStatus = ref.watch(tomtomStatusProvider);
|
final tomtomStatus = ref.watch(tomtomStatusProvider);
|
||||||
|
final communityStatus = ref.watch(communityStatusProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Settings')),
|
appBar: AppBar(title: const Text('Settings')),
|
||||||
|
|
@ -109,11 +115,12 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
SwitchListTile(
|
SwitchListTile(
|
||||||
activeColor: Colors.cyanAccent,
|
activeThumbColor: Colors.cyanAccent,
|
||||||
title: const Text('Road Alerts', style: TextStyle(fontWeight: FontWeight.bold)),
|
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,
|
value: roadAlertsEnabled,
|
||||||
onChanged: (val) {
|
onChanged: (val) {
|
||||||
ref.read(roadAlertsEnabledProvider.notifier).setEnabled(val);
|
ref.read(roadAlertsEnabledProvider.notifier).setEnabled(val);
|
||||||
|
|
@ -121,9 +128,9 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||||
),
|
),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
SwitchListTile(
|
SwitchListTile(
|
||||||
activeColor: Colors.cyanAccent,
|
activeThumbColor: Colors.cyanAccent,
|
||||||
title: const Text('Audio Warnings (TTS)', style: TextStyle(fontWeight: FontWeight.bold)),
|
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,
|
value: audioAlertsEnabled,
|
||||||
onChanged: roadAlertsEnabled
|
onChanged: roadAlertsEnabled
|
||||||
? (val) {
|
? (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(
|
Align(
|
||||||
alignment: Alignment.centerRight,
|
alignment: Alignment.centerRight,
|
||||||
child: ElevatedButton.icon(
|
child: ElevatedButton.icon(
|
||||||
onPressed: _isTestingKey
|
onPressed: _isTestingTomTom
|
||||||
? null
|
? null
|
||||||
: () async {
|
: () async {
|
||||||
setState(() => _isTestingKey = true);
|
setState(() => _isTestingTomTom = true);
|
||||||
final provider = ref.read(tomtomProviderInstance);
|
final provider = ref.read(tomtomProviderInstance);
|
||||||
final status = await provider.testConnection(_tomtomKeyController.text);
|
final status = await provider.testConnection(_tomtomKeyController.text);
|
||||||
ref.read(tomtomStatusProvider.notifier).state = status;
|
ref.read(tomtomStatusProvider.notifier).state = status;
|
||||||
setState(() => _isTestingKey = false);
|
setState(() => _isTestingTomTom = false);
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
String msg = 'Connected successfully!';
|
String msg = 'TomTom API Connected successfully!';
|
||||||
if (status == RoadAlertProviderStatus.authError) msg = 'Authentication Error: Invalid API Key.';
|
if (status == RoadAlertProviderStatus.authError) msg = 'Authentication Error: Invalid Key.';
|
||||||
if (status == RoadAlertProviderStatus.notConfigured) msg = 'Key is empty.';
|
if (status == RoadAlertProviderStatus.notConfigured) msg = 'Key is empty.';
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
icon: _isTestingKey
|
icon: _isTestingTomTom
|
||||||
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
|
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
|
||||||
: const Icon(Icons.sync),
|
: const Icon(Icons.sync),
|
||||||
label: const Text('Test Connection'),
|
label: const Text('Test Connection'),
|
||||||
|
|
@ -404,7 +502,6 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||||
color = Colors.amberAccent;
|
color = Colors.amberAccent;
|
||||||
break;
|
break;
|
||||||
case RoadAlertProviderStatus.notConfigured:
|
case RoadAlertProviderStatus.notConfigured:
|
||||||
default:
|
|
||||||
label = 'Not Set';
|
label = 'Not Set';
|
||||||
color = Colors.white38;
|
color = Colors.white38;
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,16 +1,22 @@
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'road_alert_models.dart';
|
import 'road_alert_models.dart';
|
||||||
import 'road_alert_provider_interface.dart';
|
import 'road_alert_provider_interface.dart';
|
||||||
import 'tomtom_road_alert_provider.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) {
|
final tomtomProviderInstance = Provider<TomTomRoadAlertProvider>((ref) {
|
||||||
return TomTomRoadAlertProvider();
|
return TomTomRoadAlertProvider();
|
||||||
});
|
});
|
||||||
|
|
||||||
final localProviderInstance = Provider<LocalRoadAlertProvider>((ref) {
|
final communityProviderInstance = Provider<CommunityTrafficRoadAlertProvider>((ref) {
|
||||||
return LocalRoadAlertProvider();
|
return CommunityTrafficRoadAlertProvider();
|
||||||
|
});
|
||||||
|
|
||||||
|
final mockProviderInstance = Provider<MockRoadAlertProvider>((ref) {
|
||||||
|
return MockRoadAlertProvider();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Master Enable
|
// Master Enable
|
||||||
|
|
@ -55,7 +61,7 @@ final audioAlertsEnabledProvider =
|
||||||
return AudioAlertsEnabledNotifier();
|
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> {
|
class AlertRadiusMetersNotifier extends StateNotifier<double> {
|
||||||
static const _key = 'alert_radius_meters';
|
static const _key = 'alert_radius_meters';
|
||||||
AlertRadiusMetersNotifier() : super(804.7) {
|
AlertRadiusMetersNotifier() : super(804.7) {
|
||||||
|
|
@ -76,6 +82,35 @@ final alertRadiusMetersProvider =
|
||||||
return AlertRadiusMetersNotifier();
|
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
|
// TomTom API Key
|
||||||
class TomTomApiKeyNotifier extends StateNotifier<String> {
|
class TomTomApiKeyNotifier extends StateNotifier<String> {
|
||||||
static const _key = 'tomtom_api_key';
|
static const _key = 'tomtom_api_key';
|
||||||
|
|
@ -97,23 +132,54 @@ final tomtomApiKeyProvider =
|
||||||
return TomTomApiKeyNotifier();
|
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 tomtomStatusProvider = StateProvider<RoadAlertProviderStatus>((ref) {
|
||||||
final key = ref.watch(tomtomApiKeyProvider);
|
final key = ref.watch(tomtomApiKeyProvider);
|
||||||
if (key.isEmpty) {
|
return key.isEmpty ? RoadAlertProviderStatus.notConfigured : RoadAlertProviderStatus.configured;
|
||||||
return RoadAlertProviderStatus.notConfigured;
|
});
|
||||||
}
|
|
||||||
return RoadAlertProviderStatus.configured;
|
final communityStatusProvider = StateProvider<RoadAlertProviderStatus>((ref) {
|
||||||
|
final key = ref.watch(communityApiKeyProvider);
|
||||||
|
return key.isEmpty ? RoadAlertProviderStatus.notConfigured : RoadAlertProviderStatus.configured;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Active Providers List
|
// Active Providers List
|
||||||
final activeRoadAlertProviders = Provider<List<RoadAlertProvider>>((ref) {
|
final activeRoadAlertProviders = Provider<List<RoadAlertProvider>>((ref) {
|
||||||
final tomtomKey = ref.watch(tomtomApiKeyProvider);
|
final tomtomKey = ref.watch(tomtomApiKeyProvider);
|
||||||
|
final communityKey = ref.watch(communityApiKeyProvider);
|
||||||
final list = <RoadAlertProvider>[];
|
final list = <RoadAlertProvider>[];
|
||||||
|
|
||||||
if (tomtomKey.isNotEmpty) {
|
if (tomtomKey.isNotEmpty) {
|
||||||
list.add(ref.watch(tomtomProviderInstance));
|
list.add(ref.watch(tomtomProviderInstance));
|
||||||
}
|
}
|
||||||
// Include demo local provider as fallback/demo
|
if (communityKey.isNotEmpty) {
|
||||||
list.add(ref.watch(localProviderInstance));
|
list.add(ref.watch(communityProviderInstance));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include Debug Mock Provider only in debug mode
|
||||||
|
if (kDebugMode) {
|
||||||
|
list.add(ref.watch(mockProviderInstance));
|
||||||
|
}
|
||||||
|
|
||||||
return list;
|
return list;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:math' as math;
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_tts/flutter_tts.dart';
|
import 'package:flutter_tts/flutter_tts.dart';
|
||||||
import 'package:geolocator/geolocator.dart';
|
import 'package:geolocator/geolocator.dart';
|
||||||
|
|
@ -58,9 +59,11 @@ class RoadAlertService {
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
final radiusMeters = _ref.read(alertRadiusMetersProvider);
|
final radiusMeters = _ref.read(alertRadiusMetersProvider);
|
||||||
final audioEnabled = _ref.read(audioAlertsEnabledProvider);
|
final audioEnabled = _ref.read(audioAlertsEnabledProvider);
|
||||||
|
final enabledCategories = _ref.read(enabledCategoriesProvider);
|
||||||
final tomtomApiKey = _ref.read(tomtomApiKeyProvider);
|
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;
|
bool shouldFetch = false;
|
||||||
if (_lastFetchTime == null || now.difference(_lastFetchTime!).inSeconds >= 45) {
|
if (_lastFetchTime == null || now.difference(_lastFetchTime!).inSeconds >= 45) {
|
||||||
shouldFetch = true;
|
shouldFetch = true;
|
||||||
|
|
@ -79,16 +82,27 @@ class RoadAlertService {
|
||||||
if (shouldFetch) {
|
if (shouldFetch) {
|
||||||
_lastFetchTime = now;
|
_lastFetchTime = now;
|
||||||
_lastFetchPosition = position;
|
_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 = {};
|
final Map<String, RoadAlert> deduplicated = {};
|
||||||
for (var alert in _cachedAlerts) {
|
for (var alert in _cachedAlerts) {
|
||||||
|
// Expiration check
|
||||||
if (alert.expiresAt != null && alert.expiresAt!.isBefore(now)) {
|
if (alert.expiresAt != null && alert.expiresAt!.isBefore(now)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Category toggle check
|
||||||
|
if (!enabledCategories.contains(alert.category)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
final dist = Geolocator.distanceBetween(
|
final dist = Geolocator.distanceBetween(
|
||||||
position.latitude,
|
position.latitude,
|
||||||
position.longitude,
|
position.longitude,
|
||||||
|
|
@ -101,10 +115,13 @@ class RoadAlertService {
|
||||||
final key = '${alert.providerId}_${alert.id}';
|
final key = '${alert.providerId}_${alert.id}';
|
||||||
deduplicated[key] = updatedAlert;
|
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)) {
|
if (audioEnabled && dist <= 400.0 && !_announcedAlertIds.contains(key)) {
|
||||||
_announcedAlertIds.add(key);
|
final isAhead = _isAlertAheadOfRider(position, alert);
|
||||||
_announceAlertAudio(updatedAlert);
|
if (isAhead) {
|
||||||
|
_announcedAlertIds.add(key);
|
||||||
|
_announceAlertAudio(updatedAlert);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -116,18 +133,46 @@ class RoadAlertService {
|
||||||
_alertsController.add(resultList);
|
_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(
|
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 providers = _ref.read(activeRoadAlertProviders);
|
||||||
final List<RoadAlert> newAlerts = [];
|
final List<RoadAlert> newAlerts = [];
|
||||||
|
|
||||||
for (var provider in providers) {
|
for (var provider in providers) {
|
||||||
try {
|
try {
|
||||||
|
String? key;
|
||||||
|
if (provider.id == 'tomtom') key = tomtomApiKey;
|
||||||
|
if (provider.id == 'community_traffic') key = communityApiKey;
|
||||||
|
|
||||||
final fetched = await provider.getAlerts(
|
final fetched = await provider.getAlerts(
|
||||||
latitude: lat,
|
latitude: lat,
|
||||||
longitude: lng,
|
longitude: lng,
|
||||||
radiusMeters: radiusMeters,
|
radiusMeters: radiusMeters,
|
||||||
apiKey: provider.id == 'tomtom' ? tomtomApiKey : null,
|
apiKey: key,
|
||||||
);
|
);
|
||||||
newAlerts.addAll(fetched);
|
newAlerts.addAll(fetched);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
@ -140,7 +185,7 @@ class RoadAlertService {
|
||||||
String phrase;
|
String phrase;
|
||||||
switch (alert.category) {
|
switch (alert.category) {
|
||||||
case RoadAlertCategory.police:
|
case RoadAlertCategory.police:
|
||||||
phrase = "Police reported ahead.";
|
phrase = "Police report ahead.";
|
||||||
break;
|
break;
|
||||||
case RoadAlertCategory.speedCamera:
|
case RoadAlertCategory.speedCamera:
|
||||||
phrase = "Speed camera reported ahead.";
|
phrase = "Speed camera reported ahead.";
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue