Pre-Police-Alert-Feature
This commit is contained in:
parent
781da32a7c
commit
930b7a9f55
|
|
@ -7,6 +7,8 @@ import '../../services/location_service.dart';
|
||||||
import '../../services/map/map_provider_interface.dart';
|
import '../../services/map/map_provider_interface.dart';
|
||||||
import '../../services/map/map_providers.dart';
|
import '../../services/map/map_providers.dart';
|
||||||
import '../../services/map/unified_map_view.dart';
|
import '../../services/map/unified_map_view.dart';
|
||||||
|
import '../../services/alerts/road_alert_models.dart';
|
||||||
|
import '../../services/alerts/road_alert_service.dart';
|
||||||
|
|
||||||
class MapScreen extends ConsumerStatefulWidget {
|
class MapScreen extends ConsumerStatefulWidget {
|
||||||
final int? rideId;
|
final int? rideId;
|
||||||
|
|
@ -159,6 +161,81 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
||||||
}).toList();
|
}).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showRoadAlertDetails(RoadAlert alert) {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
backgroundColor: Colors.grey[900],
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
builder: (ctx) => Padding(
|
||||||
|
padding: const EdgeInsets.all(20.0),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: alert.category.color.withValues(alpha: 0.2),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: alert.category.color),
|
||||||
|
),
|
||||||
|
child: Icon(alert.category.icon, color: alert.category.color, size: 28),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
alert.category.displayName.toUpperCase(),
|
||||||
|
style: TextStyle(
|
||||||
|
color: alert.category.color,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
letterSpacing: 1.5,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (alert.roadName != null && alert.roadName!.isNotEmpty)
|
||||||
|
Text(alert.roadName!, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white10,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
alert.sourceType == RoadAlertSourceType.userReported ? 'User Reported' : 'Verified Provider',
|
||||||
|
style: const TextStyle(fontSize: 10, color: Colors.white70),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(alert.description, style: const TextStyle(fontSize: 15, height: 1.3)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Divider(color: Colors.white24),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
if (alert.distanceFromUser != null)
|
||||||
|
Text('Distance: ${(alert.distanceFromUser! * 0.000621371).toStringAsFixed(2)} mi',
|
||||||
|
style: const TextStyle(color: Colors.cyanAccent, fontWeight: FontWeight.bold)),
|
||||||
|
Text('Source: ${alert.source}', style: const TextStyle(color: Colors.white54, fontSize: 12)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (_isLoadingHistory) {
|
if (_isLoadingHistory) {
|
||||||
|
|
@ -206,6 +283,7 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
||||||
|
|
||||||
// Live Tracking View
|
// Live Tracking View
|
||||||
final locationAsync = ref.watch(locationStreamProvider);
|
final locationAsync = ref.watch(locationStreamProvider);
|
||||||
|
final liveAlertsAsync = ref.watch(activeAlertsStreamProvider);
|
||||||
|
|
||||||
final currentPos = locationAsync.when(
|
final currentPos = locationAsync.when(
|
||||||
data: (pos) => AppLatLng(pos.latitude, pos.longitude),
|
data: (pos) => AppLatLng(pos.latitude, pos.longitude),
|
||||||
|
|
@ -230,6 +308,30 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final List<AppMapMarker> mapMarkers = [motorcycleMarker];
|
||||||
|
|
||||||
|
final liveAlerts = liveAlertsAsync.value ?? [];
|
||||||
|
for (var alert in liveAlerts) {
|
||||||
|
mapMarkers.add(
|
||||||
|
AppMapMarker(
|
||||||
|
id: 'alert_${alert.providerId}_${alert.id}',
|
||||||
|
position: AppLatLng(alert.latitude, alert.longitude),
|
||||||
|
heading: alert.heading ?? 0.0,
|
||||||
|
label: alert.category.displayName,
|
||||||
|
iconWidget: Container(
|
||||||
|
padding: const EdgeInsets.all(6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: alert.category.color.withValues(alpha: 0.9),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
boxShadow: const [BoxShadow(color: Colors.black54, blurRadius: 4)],
|
||||||
|
),
|
||||||
|
child: Icon(alert.category.icon, color: Colors.black, size: 22),
|
||||||
|
),
|
||||||
|
onTap: () => _showRoadAlertDetails(alert),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Live Map'),
|
title: const Text('Live Map'),
|
||||||
|
|
@ -257,7 +359,7 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
||||||
UnifiedMapView(
|
UnifiedMapView(
|
||||||
center: currentPos,
|
center: currentPos,
|
||||||
zoom: 17.0,
|
zoom: 17.0,
|
||||||
markers: [motorcycleMarker],
|
markers: mapMarkers,
|
||||||
),
|
),
|
||||||
if (!isFollowMode)
|
if (!isFollowMode)
|
||||||
Positioned(
|
Positioned(
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,40 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../services/sensor_fusion_service.dart';
|
import '../../services/sensor_fusion_service.dart';
|
||||||
import '../../services/map/map_provider_interface.dart';
|
import '../../services/map/map_provider_interface.dart';
|
||||||
import '../../services/map/map_providers.dart';
|
import '../../services/map/map_providers.dart';
|
||||||
|
import '../../services/alerts/road_alert_models.dart';
|
||||||
|
import '../../services/alerts/road_alert_providers.dart';
|
||||||
import '../../database/database.dart';
|
import '../../database/database.dart';
|
||||||
|
|
||||||
class SettingsScreen extends ConsumerWidget {
|
class SettingsScreen extends ConsumerStatefulWidget {
|
||||||
const SettingsScreen({super.key});
|
const SettingsScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
ConsumerState<SettingsScreen> createState() => _SettingsScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||||
|
late TextEditingController _tomtomKeyController;
|
||||||
|
bool _isTestingKey = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_tomtomKeyController = TextEditingController(text: ref.read(tomtomApiKeyProvider));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_tomtomKeyController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
final mapType = ref.watch(mapProviderTypeNotifierProvider);
|
final mapType = ref.watch(mapProviderTypeNotifierProvider);
|
||||||
|
final roadAlertsEnabled = ref.watch(roadAlertsEnabledProvider);
|
||||||
|
final audioAlertsEnabled = ref.watch(audioAlertsEnabledProvider);
|
||||||
|
final alertRadiusMeters = ref.watch(alertRadiusMetersProvider);
|
||||||
|
final tomtomStatus = ref.watch(tomtomStatusProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Settings')),
|
appBar: AppBar(title: const Text('Settings')),
|
||||||
|
|
@ -19,7 +45,7 @@ class SettingsScreen extends ConsumerWidget {
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
const Text(
|
||||||
'Map & Provider Settings',
|
'Maps & Navigation Engine',
|
||||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.cyanAccent),
|
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.cyanAccent),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
@ -71,6 +97,148 @@ class SettingsScreen extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
const Text(
|
||||||
|
'Road Alerts & Incidents',
|
||||||
|
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.cyanAccent),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Card(
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
SwitchListTile(
|
||||||
|
activeColor: 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.'),
|
||||||
|
value: roadAlertsEnabled,
|
||||||
|
onChanged: (val) {
|
||||||
|
ref.read(roadAlertsEnabledProvider.notifier).setEnabled(val);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
SwitchListTile(
|
||||||
|
activeColor: Colors.cyanAccent,
|
||||||
|
title: const Text('Audio Warnings (TTS)', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
subtitle: const Text('Announce approaching hazards over helmet headset.'),
|
||||||
|
value: audioAlertsEnabled,
|
||||||
|
onChanged: roadAlertsEnabled
|
||||||
|
? (val) {
|
||||||
|
ref.read(audioAlertsEnabledProvider.notifier).setEnabled(val);
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
const Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('Alert Radius', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||||
|
SizedBox(height: 4),
|
||||||
|
Text('Distance to detect upcoming incidents', style: TextStyle(fontSize: 12, color: Colors.white70)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
DropdownButton<double>(
|
||||||
|
value: alertRadiusMeters,
|
||||||
|
items: const [
|
||||||
|
DropdownMenuItem(value: 152.4, child: Text('500 ft')),
|
||||||
|
DropdownMenuItem(value: 304.8, child: Text('1,000 ft')),
|
||||||
|
DropdownMenuItem(value: 402.3, child: Text('1/4 Mile')),
|
||||||
|
DropdownMenuItem(value: 804.7, child: Text('1/2 Mile')),
|
||||||
|
DropdownMenuItem(value: 1609.3, child: Text('1 Mile')),
|
||||||
|
],
|
||||||
|
onChanged: roadAlertsEnabled
|
||||||
|
? (val) {
|
||||||
|
if (val != null) {
|
||||||
|
ref.read(alertRadiusMetersProvider.notifier).setRadius(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
// TomTom Provider API Key Card
|
||||||
|
Card(
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.security, color: Colors.cyanAccent),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
const Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('TomTom Traffic Provider', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||||
|
SizedBox(height: 4),
|
||||||
|
Text('Supplies Accidents, Hazards, Closures & Construction data.', style: TextStyle(fontSize: 12, color: Colors.white70)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_buildStatusBadge(tomtomStatus),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
controller: _tomtomKeyController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'TomTom API Key',
|
||||||
|
hintText: 'Enter your free TomTom API Key',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
onChanged: (val) {
|
||||||
|
ref.read(tomtomApiKeyProvider.notifier).setApiKey(val);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: _isTestingKey
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
setState(() => _isTestingKey = true);
|
||||||
|
final provider = ref.read(tomtomProviderInstance);
|
||||||
|
final status = await provider.testConnection(_tomtomKeyController.text);
|
||||||
|
ref.read(tomtomStatusProvider.notifier).state = status;
|
||||||
|
setState(() => _isTestingKey = false);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
String msg = 'Connected successfully!';
|
||||||
|
if (status == RoadAlertProviderStatus.authError) msg = 'Authentication Error: Invalid API Key.';
|
||||||
|
if (status == RoadAlertProviderStatus.notConfigured) msg = 'Key is empty.';
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon: _isTestingKey
|
||||||
|
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
|
||||||
|
: const Icon(Icons.sync),
|
||||||
|
label: const Text('Test Connection'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
const Text(
|
const Text(
|
||||||
'Sensors',
|
'Sensors',
|
||||||
|
|
@ -209,4 +377,47 @@ class SettingsScreen extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildStatusBadge(RoadAlertProviderStatus status) {
|
||||||
|
String label;
|
||||||
|
Color color;
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case RoadAlertProviderStatus.connected:
|
||||||
|
label = 'Connected';
|
||||||
|
color = Colors.greenAccent;
|
||||||
|
break;
|
||||||
|
case RoadAlertProviderStatus.configured:
|
||||||
|
label = 'Configured';
|
||||||
|
color = Colors.cyanAccent;
|
||||||
|
break;
|
||||||
|
case RoadAlertProviderStatus.authError:
|
||||||
|
label = 'Auth Error';
|
||||||
|
color = Colors.redAccent;
|
||||||
|
break;
|
||||||
|
case RoadAlertProviderStatus.rateLimited:
|
||||||
|
label = 'Rate Limited';
|
||||||
|
color = Colors.orangeAccent;
|
||||||
|
break;
|
||||||
|
case RoadAlertProviderStatus.networkError:
|
||||||
|
label = 'Network Error';
|
||||||
|
color = Colors.amberAccent;
|
||||||
|
break;
|
||||||
|
case RoadAlertProviderStatus.notConfigured:
|
||||||
|
default:
|
||||||
|
label = 'Not Set';
|
||||||
|
color = Colors.white38;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withValues(alpha: 0.15),
|
||||||
|
border: Border.all(color: color),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Text(label, style: TextStyle(color: color, fontSize: 11, fontWeight: FontWeight.bold)),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
import 'road_alert_models.dart';
|
||||||
|
import 'road_alert_provider_interface.dart';
|
||||||
|
|
||||||
|
class LocalRoadAlertProvider implements RoadAlertProvider {
|
||||||
|
@override
|
||||||
|
String get id => 'local_demo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get name => 'Demo / Local Alerts';
|
||||||
|
|
||||||
|
@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 RoadAlertProviderStatus.connected;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<RoadAlert>> getAlerts({
|
||||||
|
required double latitude,
|
||||||
|
required double longitude,
|
||||||
|
required double radiusMeters,
|
||||||
|
String? apiKey,
|
||||||
|
}) async {
|
||||||
|
// Generate simulated road alerts within 200m-800m of the user for offline demo
|
||||||
|
final now = DateTime.now();
|
||||||
|
|
||||||
|
return [
|
||||||
|
RoadAlert(
|
||||||
|
id: 'demo_hazard_1',
|
||||||
|
providerId: id,
|
||||||
|
category: RoadAlertCategory.hazard,
|
||||||
|
latitude: latitude + 0.0025,
|
||||||
|
longitude: longitude + 0.0018,
|
||||||
|
roadName: 'Main Highway / Curve 4',
|
||||||
|
description: 'Debris reported in right lane',
|
||||||
|
reportedAt: now.subtract(const Duration(minutes: 10)),
|
||||||
|
expiresAt: now.add(const Duration(minutes: 50)),
|
||||||
|
lastUpdated: now,
|
||||||
|
confidence: 0.9,
|
||||||
|
severity: RoadAlertSeverity.medium,
|
||||||
|
source: 'Demo Local Provider',
|
||||||
|
sourceType: RoadAlertSourceType.userReported,
|
||||||
|
),
|
||||||
|
RoadAlert(
|
||||||
|
id: 'demo_construction_1',
|
||||||
|
providerId: id,
|
||||||
|
category: RoadAlertCategory.construction,
|
||||||
|
latitude: latitude - 0.0031,
|
||||||
|
longitude: longitude - 0.0022,
|
||||||
|
roadName: 'Valley Expressway',
|
||||||
|
description: 'Roadwork & lane closure ahead',
|
||||||
|
reportedAt: now.subtract(const Duration(minutes: 30)),
|
||||||
|
expiresAt: now.add(const Duration(hours: 2)),
|
||||||
|
lastUpdated: now,
|
||||||
|
confidence: 0.95,
|
||||||
|
severity: RoadAlertSeverity.high,
|
||||||
|
source: 'Demo Local Provider',
|
||||||
|
sourceType: RoadAlertSourceType.providerReported,
|
||||||
|
),
|
||||||
|
RoadAlert(
|
||||||
|
id: 'demo_police_1',
|
||||||
|
providerId: id,
|
||||||
|
category: RoadAlertCategory.police,
|
||||||
|
latitude: latitude + 0.0042,
|
||||||
|
longitude: longitude - 0.0015,
|
||||||
|
roadName: 'State Route 9',
|
||||||
|
description: 'Police reported on shoulder',
|
||||||
|
reportedAt: now.subtract(const Duration(minutes: 5)),
|
||||||
|
expiresAt: now.add(const Duration(minutes: 25)),
|
||||||
|
lastUpdated: now,
|
||||||
|
confidence: 0.75,
|
||||||
|
severity: RoadAlertSeverity.medium,
|
||||||
|
source: 'Demo Community Report',
|
||||||
|
sourceType: RoadAlertSourceType.userReported,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,170 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
enum RoadAlertCategory {
|
||||||
|
police,
|
||||||
|
speedCamera,
|
||||||
|
accident,
|
||||||
|
hazard,
|
||||||
|
roadClosure,
|
||||||
|
construction,
|
||||||
|
traffic,
|
||||||
|
other,
|
||||||
|
}
|
||||||
|
|
||||||
|
extension RoadAlertCategoryExtension on RoadAlertCategory {
|
||||||
|
String get displayName {
|
||||||
|
switch (this) {
|
||||||
|
case RoadAlertCategory.police:
|
||||||
|
return 'Police';
|
||||||
|
case RoadAlertCategory.speedCamera:
|
||||||
|
return 'Speed Camera';
|
||||||
|
case RoadAlertCategory.accident:
|
||||||
|
return 'Accident';
|
||||||
|
case RoadAlertCategory.hazard:
|
||||||
|
return 'Hazard';
|
||||||
|
case RoadAlertCategory.roadClosure:
|
||||||
|
return 'Road Closure';
|
||||||
|
case RoadAlertCategory.construction:
|
||||||
|
return 'Construction';
|
||||||
|
case RoadAlertCategory.traffic:
|
||||||
|
return 'Traffic Delay';
|
||||||
|
case RoadAlertCategory.other:
|
||||||
|
return 'Other';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IconData get icon {
|
||||||
|
switch (this) {
|
||||||
|
case RoadAlertCategory.police:
|
||||||
|
return Icons.local_police;
|
||||||
|
case RoadAlertCategory.speedCamera:
|
||||||
|
return Icons.camera_alt;
|
||||||
|
case RoadAlertCategory.accident:
|
||||||
|
return Icons.car_crash;
|
||||||
|
case RoadAlertCategory.hazard:
|
||||||
|
return Icons.warning_amber;
|
||||||
|
case RoadAlertCategory.roadClosure:
|
||||||
|
return Icons.do_not_disturb_on;
|
||||||
|
case RoadAlertCategory.construction:
|
||||||
|
return Icons.engineering;
|
||||||
|
case RoadAlertCategory.traffic:
|
||||||
|
return Icons.traffic;
|
||||||
|
case RoadAlertCategory.other:
|
||||||
|
return Icons.report_problem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Color get color {
|
||||||
|
switch (this) {
|
||||||
|
case RoadAlertCategory.police:
|
||||||
|
return Colors.blueAccent;
|
||||||
|
case RoadAlertCategory.speedCamera:
|
||||||
|
return Colors.purpleAccent;
|
||||||
|
case RoadAlertCategory.accident:
|
||||||
|
return Colors.redAccent;
|
||||||
|
case RoadAlertCategory.hazard:
|
||||||
|
return Colors.orangeAccent;
|
||||||
|
case RoadAlertCategory.roadClosure:
|
||||||
|
return Colors.deepOrange;
|
||||||
|
case RoadAlertCategory.construction:
|
||||||
|
return Colors.amber;
|
||||||
|
case RoadAlertCategory.traffic:
|
||||||
|
return Colors.yellowAccent;
|
||||||
|
case RoadAlertCategory.other:
|
||||||
|
return Colors.cyanAccent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RoadAlertSeverity { low, medium, high, critical }
|
||||||
|
|
||||||
|
enum RoadAlertSourceType { providerReported, userReported }
|
||||||
|
|
||||||
|
enum RoadAlertProviderStatus {
|
||||||
|
notConfigured,
|
||||||
|
configured,
|
||||||
|
connected,
|
||||||
|
rateLimited,
|
||||||
|
authError,
|
||||||
|
networkError,
|
||||||
|
}
|
||||||
|
|
||||||
|
class RoadAlert {
|
||||||
|
final String id;
|
||||||
|
final String providerId;
|
||||||
|
final RoadAlertCategory category;
|
||||||
|
final double latitude;
|
||||||
|
final double longitude;
|
||||||
|
final double? heading;
|
||||||
|
final String? roadName;
|
||||||
|
final String description;
|
||||||
|
final DateTime reportedAt;
|
||||||
|
final DateTime? expiresAt;
|
||||||
|
final DateTime lastUpdated;
|
||||||
|
final double? confidence; // 0.0 to 1.0
|
||||||
|
final RoadAlertSeverity severity;
|
||||||
|
final String source;
|
||||||
|
final String? sourceUrl;
|
||||||
|
final double? distanceFromUser; // meters
|
||||||
|
final RoadAlertSourceType sourceType;
|
||||||
|
|
||||||
|
const RoadAlert({
|
||||||
|
required this.id,
|
||||||
|
required this.providerId,
|
||||||
|
required this.category,
|
||||||
|
required this.latitude,
|
||||||
|
required this.longitude,
|
||||||
|
this.heading,
|
||||||
|
this.roadName,
|
||||||
|
required this.description,
|
||||||
|
required this.reportedAt,
|
||||||
|
this.expiresAt,
|
||||||
|
required this.lastUpdated,
|
||||||
|
this.confidence,
|
||||||
|
this.severity = RoadAlertSeverity.medium,
|
||||||
|
required this.source,
|
||||||
|
this.sourceUrl,
|
||||||
|
this.distanceFromUser,
|
||||||
|
this.sourceType = RoadAlertSourceType.providerReported,
|
||||||
|
});
|
||||||
|
|
||||||
|
RoadAlert copyWith({
|
||||||
|
String? id,
|
||||||
|
String? providerId,
|
||||||
|
RoadAlertCategory? category,
|
||||||
|
double? latitude,
|
||||||
|
double? longitude,
|
||||||
|
double? heading,
|
||||||
|
String? roadName,
|
||||||
|
String? description,
|
||||||
|
DateTime? reportedAt,
|
||||||
|
DateTime? expiresAt,
|
||||||
|
DateTime? lastUpdated,
|
||||||
|
double? confidence,
|
||||||
|
RoadAlertSeverity? severity,
|
||||||
|
String? source,
|
||||||
|
String? sourceUrl,
|
||||||
|
double? distanceFromUser,
|
||||||
|
RoadAlertSourceType? sourceType,
|
||||||
|
}) {
|
||||||
|
return RoadAlert(
|
||||||
|
id: id ?? this.id,
|
||||||
|
providerId: providerId ?? this.providerId,
|
||||||
|
category: category ?? this.category,
|
||||||
|
latitude: latitude ?? this.latitude,
|
||||||
|
longitude: longitude ?? this.longitude,
|
||||||
|
heading: heading ?? this.heading,
|
||||||
|
roadName: roadName ?? this.roadName,
|
||||||
|
description: description ?? this.description,
|
||||||
|
reportedAt: reportedAt ?? this.reportedAt,
|
||||||
|
expiresAt: expiresAt ?? this.expiresAt,
|
||||||
|
lastUpdated: lastUpdated ?? this.lastUpdated,
|
||||||
|
confidence: confidence ?? this.confidence,
|
||||||
|
severity: severity ?? this.severity,
|
||||||
|
source: source ?? this.source,
|
||||||
|
sourceUrl: sourceUrl ?? this.sourceUrl,
|
||||||
|
distanceFromUser: distanceFromUser ?? this.distanceFromUser,
|
||||||
|
sourceType: sourceType ?? this.sourceType,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
import 'road_alert_models.dart';
|
||||||
|
|
||||||
|
abstract class RoadAlertProvider {
|
||||||
|
String get id;
|
||||||
|
String get name;
|
||||||
|
Set<RoadAlertCategory> get supportedCategories;
|
||||||
|
|
||||||
|
Future<List<RoadAlert>> getAlerts({
|
||||||
|
required double latitude,
|
||||||
|
required double longitude,
|
||||||
|
required double radiusMeters,
|
||||||
|
String? apiKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<RoadAlertProviderStatus> testConnection(String apiKey);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,119 @@
|
||||||
|
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';
|
||||||
|
|
||||||
|
final tomtomProviderInstance = Provider<TomTomRoadAlertProvider>((ref) {
|
||||||
|
return TomTomRoadAlertProvider();
|
||||||
|
});
|
||||||
|
|
||||||
|
final localProviderInstance = Provider<LocalRoadAlertProvider>((ref) {
|
||||||
|
return LocalRoadAlertProvider();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Master Enable
|
||||||
|
class RoadAlertsEnabledNotifier extends StateNotifier<bool> {
|
||||||
|
static const _key = 'road_alerts_enabled';
|
||||||
|
RoadAlertsEnabledNotifier() : super(true) {
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
Future<void> _load() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
state = prefs.getBool(_key) ?? true;
|
||||||
|
}
|
||||||
|
Future<void> setEnabled(bool enabled) async {
|
||||||
|
state = enabled;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_key, enabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final roadAlertsEnabledProvider =
|
||||||
|
StateNotifierProvider<RoadAlertsEnabledNotifier, bool>((ref) {
|
||||||
|
return RoadAlertsEnabledNotifier();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Audio Alerts Toggle
|
||||||
|
class AudioAlertsEnabledNotifier extends StateNotifier<bool> {
|
||||||
|
static const _key = 'audio_alerts_enabled';
|
||||||
|
AudioAlertsEnabledNotifier() : super(true) {
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
Future<void> _load() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
state = prefs.getBool(_key) ?? true;
|
||||||
|
}
|
||||||
|
Future<void> setEnabled(bool enabled) async {
|
||||||
|
state = enabled;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_key, enabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final audioAlertsEnabledProvider =
|
||||||
|
StateNotifierProvider<AudioAlertsEnabledNotifier, bool>((ref) {
|
||||||
|
return AudioAlertsEnabledNotifier();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Alert Radius in Meters (500ft = 152.4m, 1000ft = 304.8m, 0.25mi = 402.3m, 0.5mi = 804.7m, 1mi = 1609.3m)
|
||||||
|
class AlertRadiusMetersNotifier extends StateNotifier<double> {
|
||||||
|
static const _key = 'alert_radius_meters';
|
||||||
|
AlertRadiusMetersNotifier() : super(804.7) {
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
Future<void> _load() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
state = prefs.getDouble(_key) ?? 804.7;
|
||||||
|
}
|
||||||
|
Future<void> setRadius(double radiusMeters) async {
|
||||||
|
state = radiusMeters;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setDouble(_key, radiusMeters);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final alertRadiusMetersProvider =
|
||||||
|
StateNotifierProvider<AlertRadiusMetersNotifier, double>((ref) {
|
||||||
|
return AlertRadiusMetersNotifier();
|
||||||
|
});
|
||||||
|
|
||||||
|
// TomTom API Key
|
||||||
|
class TomTomApiKeyNotifier extends StateNotifier<String> {
|
||||||
|
static const _key = 'tomtom_api_key';
|
||||||
|
TomTomApiKeyNotifier() : 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 tomtomApiKeyProvider =
|
||||||
|
StateNotifierProvider<TomTomApiKeyNotifier, String>((ref) {
|
||||||
|
return TomTomApiKeyNotifier();
|
||||||
|
});
|
||||||
|
|
||||||
|
// TomTom Connection Status
|
||||||
|
final tomtomStatusProvider = StateProvider<RoadAlertProviderStatus>((ref) {
|
||||||
|
final key = ref.watch(tomtomApiKeyProvider);
|
||||||
|
if (key.isEmpty) {
|
||||||
|
return RoadAlertProviderStatus.notConfigured;
|
||||||
|
}
|
||||||
|
return RoadAlertProviderStatus.configured;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Active Providers List
|
||||||
|
final activeRoadAlertProviders = Provider<List<RoadAlertProvider>>((ref) {
|
||||||
|
final tomtomKey = ref.watch(tomtomApiKeyProvider);
|
||||||
|
final list = <RoadAlertProvider>[];
|
||||||
|
if (tomtomKey.isNotEmpty) {
|
||||||
|
list.add(ref.watch(tomtomProviderInstance));
|
||||||
|
}
|
||||||
|
// Include demo local provider as fallback/demo
|
||||||
|
list.add(ref.watch(localProviderInstance));
|
||||||
|
return list;
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,175 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_tts/flutter_tts.dart';
|
||||||
|
import 'package:geolocator/geolocator.dart';
|
||||||
|
import '../location_service.dart';
|
||||||
|
import 'road_alert_models.dart';
|
||||||
|
import 'road_alert_providers.dart';
|
||||||
|
|
||||||
|
final roadAlertServiceProvider = Provider<RoadAlertService>((ref) {
|
||||||
|
final service = RoadAlertService(ref);
|
||||||
|
ref.onDispose(() => service.dispose());
|
||||||
|
return service;
|
||||||
|
});
|
||||||
|
|
||||||
|
final activeAlertsStreamProvider = StreamProvider<List<RoadAlert>>((ref) {
|
||||||
|
return ref.watch(roadAlertServiceProvider).alertsStream;
|
||||||
|
});
|
||||||
|
|
||||||
|
class RoadAlertService {
|
||||||
|
final Ref _ref;
|
||||||
|
final FlutterTts _tts = FlutterTts();
|
||||||
|
final _alertsController = StreamController<List<RoadAlert>>.broadcast();
|
||||||
|
|
||||||
|
StreamSubscription<Position>? _locationSub;
|
||||||
|
|
||||||
|
DateTime? _lastFetchTime;
|
||||||
|
Position? _lastFetchPosition;
|
||||||
|
List<RoadAlert> _cachedAlerts = [];
|
||||||
|
final Set<String> _announcedAlertIds = {};
|
||||||
|
|
||||||
|
Stream<List<RoadAlert>> get alertsStream => _alertsController.stream;
|
||||||
|
|
||||||
|
RoadAlertService(this._ref) {
|
||||||
|
_initTts();
|
||||||
|
_startListening();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initTts() {
|
||||||
|
_tts.setLanguage("en-US");
|
||||||
|
_tts.setSpeechRate(0.5);
|
||||||
|
_tts.setVolume(1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startListening() {
|
||||||
|
_locationSub = _ref.read(locationServiceProvider).getFilteredPositionStream().listen((position) {
|
||||||
|
_processPosition(position);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _processPosition(Position position) async {
|
||||||
|
final isEnabled = _ref.read(roadAlertsEnabledProvider);
|
||||||
|
if (!isEnabled) {
|
||||||
|
_cachedAlerts = [];
|
||||||
|
_alertsController.add([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
final radiusMeters = _ref.read(alertRadiusMetersProvider);
|
||||||
|
final audioEnabled = _ref.read(audioAlertsEnabledProvider);
|
||||||
|
final tomtomApiKey = _ref.read(tomtomApiKeyProvider);
|
||||||
|
|
||||||
|
// Throttle fetches: fetch if 45s passed OR moved > 500m
|
||||||
|
bool shouldFetch = false;
|
||||||
|
if (_lastFetchTime == null || now.difference(_lastFetchTime!).inSeconds >= 45) {
|
||||||
|
shouldFetch = true;
|
||||||
|
} else if (_lastFetchPosition != null) {
|
||||||
|
final dist = Geolocator.distanceBetween(
|
||||||
|
_lastFetchPosition!.latitude,
|
||||||
|
_lastFetchPosition!.longitude,
|
||||||
|
position.latitude,
|
||||||
|
position.longitude,
|
||||||
|
);
|
||||||
|
if (dist > 500.0) {
|
||||||
|
shouldFetch = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldFetch) {
|
||||||
|
_lastFetchTime = now;
|
||||||
|
_lastFetchPosition = position;
|
||||||
|
await _fetchAlertsFromProviders(position.latitude, position.longitude, radiusMeters, tomtomApiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update distance for cached alerts & filter out expired ones
|
||||||
|
final Map<String, RoadAlert> deduplicated = {};
|
||||||
|
for (var alert in _cachedAlerts) {
|
||||||
|
if (alert.expiresAt != null && alert.expiresAt!.isBefore(now)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final dist = Geolocator.distanceBetween(
|
||||||
|
position.latitude,
|
||||||
|
position.longitude,
|
||||||
|
alert.latitude,
|
||||||
|
alert.longitude,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (dist <= radiusMeters * 1.5) {
|
||||||
|
final updatedAlert = alert.copyWith(distanceFromUser: dist);
|
||||||
|
final key = '${alert.providerId}_${alert.id}';
|
||||||
|
deduplicated[key] = updatedAlert;
|
||||||
|
|
||||||
|
// Check audio alert approach (< 400m)
|
||||||
|
if (audioEnabled && dist <= 400.0 && !_announcedAlertIds.contains(key)) {
|
||||||
|
_announcedAlertIds.add(key);
|
||||||
|
_announceAlertAudio(updatedAlert);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final resultList = deduplicated.values.toList()
|
||||||
|
..sort((a, b) => (a.distanceFromUser ?? 0).compareTo(b.distanceFromUser ?? 0));
|
||||||
|
|
||||||
|
_cachedAlerts = resultList;
|
||||||
|
_alertsController.add(resultList);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchAlertsFromProviders(
|
||||||
|
double lat, double lng, double radiusMeters, String tomtomApiKey) async {
|
||||||
|
final providers = _ref.read(activeRoadAlertProviders);
|
||||||
|
final List<RoadAlert> newAlerts = [];
|
||||||
|
|
||||||
|
for (var provider in providers) {
|
||||||
|
try {
|
||||||
|
final fetched = await provider.getAlerts(
|
||||||
|
latitude: lat,
|
||||||
|
longitude: lng,
|
||||||
|
radiusMeters: radiusMeters,
|
||||||
|
apiKey: provider.id == 'tomtom' ? tomtomApiKey : null,
|
||||||
|
);
|
||||||
|
newAlerts.addAll(fetched);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
_cachedAlerts = newAlerts;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _announceAlertAudio(RoadAlert alert) {
|
||||||
|
String phrase;
|
||||||
|
switch (alert.category) {
|
||||||
|
case RoadAlertCategory.police:
|
||||||
|
phrase = "Police reported ahead.";
|
||||||
|
break;
|
||||||
|
case RoadAlertCategory.speedCamera:
|
||||||
|
phrase = "Speed camera reported ahead.";
|
||||||
|
break;
|
||||||
|
case RoadAlertCategory.accident:
|
||||||
|
phrase = "Accident reported ahead.";
|
||||||
|
break;
|
||||||
|
case RoadAlertCategory.hazard:
|
||||||
|
phrase = "Road hazard ahead.";
|
||||||
|
break;
|
||||||
|
case RoadAlertCategory.roadClosure:
|
||||||
|
phrase = "Road closure ahead.";
|
||||||
|
break;
|
||||||
|
case RoadAlertCategory.construction:
|
||||||
|
phrase = "Construction zone ahead.";
|
||||||
|
break;
|
||||||
|
case RoadAlertCategory.traffic:
|
||||||
|
phrase = "Traffic delay ahead.";
|
||||||
|
break;
|
||||||
|
case RoadAlertCategory.other:
|
||||||
|
phrase = "Traffic alert ahead.";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_tts.speak(phrase);
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
_locationSub?.cancel();
|
||||||
|
_alertsController.close();
|
||||||
|
_tts.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,162 @@
|
||||||
|
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';
|
||||||
|
|
||||||
|
class TomTomRoadAlertProvider implements RoadAlertProvider {
|
||||||
|
@override
|
||||||
|
String get id => 'tomtom';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get name => 'TomTom Traffic';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<RoadAlertCategory> get supportedCategories => const {
|
||||||
|
RoadAlertCategory.accident,
|
||||||
|
RoadAlertCategory.hazard,
|
||||||
|
RoadAlertCategory.construction,
|
||||||
|
RoadAlertCategory.roadClosure,
|
||||||
|
RoadAlertCategory.traffic,
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<RoadAlertProviderStatus> testConnection(String apiKey) async {
|
||||||
|
if (apiKey.trim().isEmpty) {
|
||||||
|
return RoadAlertProviderStatus.notConfigured;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Test query using a small bounding box over San Francisco
|
||||||
|
final url = Uri.parse(
|
||||||
|
'https://api.tomtom.com/traffic/services/5/incidentDetails/s3/-122.42,37.77,-122.40,37.79/10/-1/json?key=$apiKey&language=en-GB');
|
||||||
|
final response = await http.get(url).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) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Convert radius to bounding box offset (~111,000m per degree of latitude)
|
||||||
|
final latOffset = radiusMeters / 111000.0;
|
||||||
|
final lngOffset = radiusMeters / (111000.0 * math.cos(latitude * math.pi / 180.0));
|
||||||
|
|
||||||
|
final minLng = longitude - lngOffset;
|
||||||
|
final minLat = latitude - latOffset;
|
||||||
|
final maxLng = longitude + lngOffset;
|
||||||
|
final maxLat = latitude + latOffset;
|
||||||
|
|
||||||
|
final bboxStr = '${minLng.toStringAsFixed(4)},${minLat.toStringAsFixed(4)},${maxLng.toStringAsFixed(4)},${maxLat.toStringAsFixed(4)}';
|
||||||
|
|
||||||
|
final url = Uri.parse(
|
||||||
|
'https://api.tomtom.com/traffic/services/5/incidentDetails/s3/$bboxStr/10/-1/json?key=$apiKey&language=en-GB');
|
||||||
|
|
||||||
|
final response = await http.get(url).timeout(const Duration(seconds: 8));
|
||||||
|
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
final data = json.decode(response.body);
|
||||||
|
final List<dynamic> incidents = data['incidents'] ?? [];
|
||||||
|
final List<RoadAlert> alerts = [];
|
||||||
|
|
||||||
|
for (var inc in incidents) {
|
||||||
|
final props = inc['properties'] ?? {};
|
||||||
|
final geometry = inc['geometry'] ?? {};
|
||||||
|
final coords = geometry['coordinates'];
|
||||||
|
|
||||||
|
double incLat = latitude;
|
||||||
|
double incLng = longitude;
|
||||||
|
|
||||||
|
if (coords is List && coords.isNotEmpty) {
|
||||||
|
if (coords[0] is List) {
|
||||||
|
incLng = (coords[0][0] as num).toDouble();
|
||||||
|
incLat = (coords[0][1] as num).toDouble();
|
||||||
|
} else if (coords.length >= 2) {
|
||||||
|
incLng = (coords[0] as num).toDouble();
|
||||||
|
incLat = (coords[1] as num).toDouble();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final iconCategory = (props['iconCategory'] as num?)?.toInt() ?? 0;
|
||||||
|
final RoadAlertCategory category = _mapTomTomIconCategory(iconCategory);
|
||||||
|
|
||||||
|
final description = (props['events'] != null && (props['events'] as List).isNotEmpty)
|
||||||
|
? props['events'][0]['description'] ?? 'Traffic incident'
|
||||||
|
: (props['iconCategory'] != null ? 'Incident category $iconCategory' : 'Road Incident');
|
||||||
|
|
||||||
|
final alertId = props['id']?.toString() ?? '${incLat}_${incLng}';
|
||||||
|
|
||||||
|
alerts.add(RoadAlert(
|
||||||
|
id: alertId,
|
||||||
|
providerId: id,
|
||||||
|
category: category,
|
||||||
|
latitude: incLat,
|
||||||
|
longitude: incLng,
|
||||||
|
roadName: props['from'] ?? props['to'],
|
||||||
|
description: description.toString(),
|
||||||
|
reportedAt: DateTime.now(),
|
||||||
|
expiresAt: DateTime.now().add(const Duration(hours: 1)),
|
||||||
|
lastUpdated: DateTime.now(),
|
||||||
|
severity: _mapTomTomMagnitude(props['magnitudeOfDelay'] as num?),
|
||||||
|
source: 'TomTom Traffic API',
|
||||||
|
sourceType: RoadAlertSourceType.providerReported,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return alerts;
|
||||||
|
} catch (_) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RoadAlertCategory _mapTomTomIconCategory(int iconCategory) {
|
||||||
|
switch (iconCategory) {
|
||||||
|
case 1: // Accident
|
||||||
|
return RoadAlertCategory.accident;
|
||||||
|
case 2: // Fog / Hazard
|
||||||
|
case 3: // Dangerous Conditions
|
||||||
|
case 4: // Rain / Ice / Snow
|
||||||
|
case 5: // Wind / Weather
|
||||||
|
return RoadAlertCategory.hazard;
|
||||||
|
case 6: // Lane restriction / Construction
|
||||||
|
case 7: // Roadworks
|
||||||
|
return RoadAlertCategory.construction;
|
||||||
|
case 8: // Road closed
|
||||||
|
case 9: // Blocked road
|
||||||
|
return RoadAlertCategory.roadClosure;
|
||||||
|
case 10: // Traffic Jam
|
||||||
|
case 11: // Slow traffic
|
||||||
|
return RoadAlertCategory.traffic;
|
||||||
|
default:
|
||||||
|
return RoadAlertCategory.hazard;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RoadAlertSeverity _mapTomTomMagnitude(num? magnitude) {
|
||||||
|
if (magnitude == null) return RoadAlertSeverity.medium;
|
||||||
|
if (magnitude >= 3) return RoadAlertSeverity.critical;
|
||||||
|
if (magnitude >= 2) return RoadAlertSeverity.high;
|
||||||
|
if (magnitude >= 1) return RoadAlertSeverity.medium;
|
||||||
|
return RoadAlertSeverity.low;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:motorcycle_dash/services/alerts/road_alert_models.dart';
|
||||||
|
import 'package:motorcycle_dash/services/alerts/tomtom_road_alert_provider.dart';
|
||||||
|
import 'package:motorcycle_dash/services/alerts/local_road_alert_provider.dart';
|
||||||
|
import 'package:motorcycle_dash/services/alerts/road_alert_providers.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
group('Road Alert System Tests', () {
|
||||||
|
test('RoadAlertCategory display names and colors', () {
|
||||||
|
expect(RoadAlertCategory.police.displayName, equals('Police'));
|
||||||
|
expect(RoadAlertCategory.accident.displayName, equals('Accident'));
|
||||||
|
expect(RoadAlertCategory.hazard.displayName, equals('Hazard'));
|
||||||
|
|
||||||
|
expect(RoadAlertCategory.police.color, isNotNull);
|
||||||
|
expect(RoadAlertCategory.accident.icon, isNotNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('TomTomRoadAlertProvider capability advertising', () {
|
||||||
|
final provider = TomTomRoadAlertProvider();
|
||||||
|
expect(provider.id, equals('tomtom'));
|
||||||
|
expect(provider.name, equals('TomTom Traffic'));
|
||||||
|
|
||||||
|
// Verify TomTom explicitly advertises its supported categories (No police/speed-traps)
|
||||||
|
expect(provider.supportedCategories, contains(RoadAlertCategory.accident));
|
||||||
|
expect(provider.supportedCategories, contains(RoadAlertCategory.hazard));
|
||||||
|
expect(provider.supportedCategories, contains(RoadAlertCategory.construction));
|
||||||
|
expect(provider.supportedCategories, contains(RoadAlertCategory.roadClosure));
|
||||||
|
expect(provider.supportedCategories, contains(RoadAlertCategory.traffic));
|
||||||
|
|
||||||
|
expect(provider.supportedCategories, isNot(contains(RoadAlertCategory.police)));
|
||||||
|
expect(provider.supportedCategories, isNot(contains(RoadAlertCategory.speedCamera)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('LocalRoadAlertProvider returns demo alerts', () async {
|
||||||
|
final localProvider = LocalRoadAlertProvider();
|
||||||
|
final alerts = await localProvider.getAlerts(
|
||||||
|
latitude: 37.7749,
|
||||||
|
longitude: -122.4194,
|
||||||
|
radiusMeters: 1000.0,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(alerts.isNotEmpty, isTrue);
|
||||||
|
expect(alerts.any((a) => a.category == RoadAlertCategory.hazard), isTrue);
|
||||||
|
expect(alerts.any((a) => a.category == RoadAlertCategory.construction), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Road alert providers & setting defaults', () {
|
||||||
|
final container = ProviderContainer();
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
expect(container.read(roadAlertsEnabledProvider), isTrue);
|
||||||
|
expect(container.read(audioAlertsEnabledProvider), isTrue);
|
||||||
|
expect(container.read(alertRadiusMetersProvider), equals(804.7));
|
||||||
|
expect(container.read(tomtomApiKeyProvider), isEmpty);
|
||||||
|
expect(container.read(tomtomStatusProvider), equals(RoadAlertProviderStatus.notConfigured));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue