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> {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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: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;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,13 +115,16 @@ 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)) {
|
||||
final isAhead = _isAlertAheadOfRider(position, alert);
|
||||
if (isAhead) {
|
||||
_announcedAlertIds.add(key);
|
||||
_announceAlertAudio(updatedAlert);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final resultList = deduplicated.values.toList()
|
||||
..sort((a, b) => (a.distanceFromUser ?? 0).compareTo(b.distanceFromUser ?? 0));
|
||||
|
|
@ -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.";
|
||||
|
|
|
|||
|
|
@ -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