Production-hardening: road alerts, lint cleanup, http dep, OSM null fix
This commit is contained in:
parent
3e9f8eb8e7
commit
c56233981e
|
|
@ -4,7 +4,6 @@ import '../../services/compass_service.dart';
|
||||||
import '../rides/ride_state_machine.dart';
|
import '../rides/ride_state_machine.dart';
|
||||||
import '../../models/telemetry_sample.dart';
|
import '../../models/telemetry_sample.dart';
|
||||||
import '../../services/telemetry_service.dart';
|
import '../../services/telemetry_service.dart';
|
||||||
import '../../services/telemetry_service.dart';
|
|
||||||
import 'dashboard_config.dart';
|
import 'dashboard_config.dart';
|
||||||
import 'widgets/g_force_visualizer.dart';
|
import 'widgets/g_force_visualizer.dart';
|
||||||
import 'widgets/lean_visualizer.dart';
|
import 'widgets/lean_visualizer.dart';
|
||||||
|
|
|
||||||
|
|
@ -277,7 +277,7 @@ class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
|
||||||
child: ListView.separated(
|
child: ListView.separated(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
itemCount: filtered.length,
|
itemCount: filtered.length,
|
||||||
separatorBuilder: (_, __) => const Divider(height: 1, color: Colors.white10),
|
separatorBuilder: (_, _) => const Divider(height: 1, color: Colors.white10),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final make = filtered[index];
|
final make = filtered[index];
|
||||||
return ListTile(
|
return ListTile(
|
||||||
|
|
@ -316,12 +316,12 @@ class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
|
||||||
child: ListView.separated(
|
child: ListView.separated(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
itemCount: filtered.length,
|
itemCount: filtered.length,
|
||||||
separatorBuilder: (_, __) => const Divider(height: 1, color: Colors.white10),
|
separatorBuilder: (_, _) => const Divider(height: 1, color: Colors.white10),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final model = filtered[index];
|
final model = filtered[index];
|
||||||
return ListTile(
|
return ListTile(
|
||||||
title: Text(model.name, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 18)),
|
title: Text(model.name, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 18)),
|
||||||
subtitle: Text('${_selectedYear} ${_selectedMake?.name}'),
|
subtitle: Text('$_selectedYear ${_selectedMake?.name}'),
|
||||||
trailing: const Icon(Icons.chevron_right, color: Colors.white30),
|
trailing: const Icon(Icons.chevron_right, color: Colors.white30),
|
||||||
onTap: () => _selectModel(model),
|
onTap: () => _selectModel(model),
|
||||||
);
|
);
|
||||||
|
|
@ -336,7 +336,7 @@ class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
|
||||||
return ListView.separated(
|
return ListView.separated(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
itemCount: _variants.length,
|
itemCount: _variants.length,
|
||||||
separatorBuilder: (_, __) => const SizedBox(height: 12),
|
separatorBuilder: (_, _) => const SizedBox(height: 12),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final variant = _variants[index];
|
final variant = _variants[index];
|
||||||
return Card(
|
return Card(
|
||||||
|
|
@ -369,7 +369,7 @@ class _AddMotorcycleScreenState extends ConsumerState<AddMotorcycleScreen> {
|
||||||
const Icon(Icons.two_wheeler, size: 64, color: Colors.cyanAccent),
|
const Icon(Icons.two_wheeler, size: 64, color: Colors.cyanAccent),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
'${_selectedYear} ${_selectedMake?.name} ${_selectedModel?.name}',
|
'$_selectedYear ${_selectedMake?.name} ${_selectedModel?.name}',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
|
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -287,13 +287,13 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
||||||
|
|
||||||
final currentPos = locationAsync.when(
|
final currentPos = locationAsync.when(
|
||||||
data: (pos) => AppLatLng(pos.latitude, pos.longitude),
|
data: (pos) => AppLatLng(pos.latitude, pos.longitude),
|
||||||
error: (_, __) => _fallbackLocation,
|
error: (_, _) => _fallbackLocation,
|
||||||
loading: () => _fallbackLocation,
|
loading: () => _fallbackLocation,
|
||||||
);
|
);
|
||||||
|
|
||||||
final currentHeading = locationAsync.when(
|
final currentHeading = locationAsync.when(
|
||||||
data: (pos) => pos.heading,
|
data: (pos) => pos.heading,
|
||||||
error: (_, __) => 0.0,
|
error: (_, _) => 0.0,
|
||||||
loading: () => 0.0,
|
loading: () => 0.0,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
@ -245,6 +246,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||||
? null
|
? null
|
||||||
: () async {
|
: () async {
|
||||||
setState(() => _isTestingCommunity = true);
|
setState(() => _isTestingCommunity = true);
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
final provider = ref.read(communityProviderInstance);
|
final provider = ref.read(communityProviderInstance);
|
||||||
final status = await provider.testConnection(_communityKeyController.text);
|
final status = await provider.testConnection(_communityKeyController.text);
|
||||||
ref.read(communityStatusProvider.notifier).state = status;
|
ref.read(communityStatusProvider.notifier).state = status;
|
||||||
|
|
@ -254,7 +256,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||||
String msg = 'Community API Connected successfully!';
|
String msg = 'Community API Connected successfully!';
|
||||||
if (status == RoadAlertProviderStatus.authError) msg = 'Authentication Error: Invalid 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)));
|
messenger.showSnackBar(SnackBar(content: Text(msg)));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
icon: _isTestingCommunity
|
icon: _isTestingCommunity
|
||||||
|
|
@ -314,6 +316,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||||
? null
|
? null
|
||||||
: () async {
|
: () async {
|
||||||
setState(() => _isTestingTomTom = true);
|
setState(() => _isTestingTomTom = true);
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
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;
|
||||||
|
|
@ -323,7 +326,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||||
String msg = 'TomTom API Connected successfully!';
|
String msg = 'TomTom API Connected successfully!';
|
||||||
if (status == RoadAlertProviderStatus.authError) msg = 'Authentication Error: Invalid 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)));
|
messenger.showSnackBar(SnackBar(content: Text(msg)));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
icon: _isTestingTomTom
|
icon: _isTestingTomTom
|
||||||
|
|
@ -337,6 +340,61 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Developer Test Mode Section (Debug Mode Only)
|
||||||
|
if (kDebugMode) ...[
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
const Text(
|
||||||
|
'Developer Options',
|
||||||
|
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.orangeAccent),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Card(
|
||||||
|
color: Colors.orange.withValues(alpha: 0.1),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
side: const BorderSide(color: Colors.orangeAccent),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.bug_report, color: Colors.orangeAccent),
|
||||||
|
SizedBox(width: 12),
|
||||||
|
Text('Road Alert Test Mode', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.orangeAccent)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Text('Generate simulated test alerts for real-device testing (Debug Build Only).', style: TextStyle(fontSize: 12, color: Colors.white70)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
ElevatedButton.icon(
|
||||||
|
icon: const Icon(Icons.local_police, color: Colors.blueAccent, size: 18),
|
||||||
|
label: const Text('Test Police (100m)'),
|
||||||
|
onPressed: () {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Simulated Police Alert 100m ahead triggered.')));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
icon: const Icon(Icons.warning, color: Colors.orangeAccent, size: 18),
|
||||||
|
label: const Text('Test Hazard (250m)'),
|
||||||
|
onPressed: () {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Simulated Hazard Alert 250m ahead triggered.')));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
const Text(
|
const Text(
|
||||||
'Sensors',
|
'Sensors',
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:math' as math;
|
import 'package:flutter/foundation.dart';
|
||||||
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';
|
||||||
|
|
@ -26,6 +26,9 @@ class RoadAlertService {
|
||||||
|
|
||||||
DateTime? _lastFetchTime;
|
DateTime? _lastFetchTime;
|
||||||
Position? _lastFetchPosition;
|
Position? _lastFetchPosition;
|
||||||
|
bool _isFetching = false; // Request serialization lock
|
||||||
|
DateTime? _lastSpeechTime; // Audio cooldown timer
|
||||||
|
|
||||||
List<RoadAlert> _cachedAlerts = [];
|
List<RoadAlert> _cachedAlerts = [];
|
||||||
final Set<String> _announcedAlertIds = {};
|
final Set<String> _announcedAlertIds = {};
|
||||||
|
|
||||||
|
|
@ -37,12 +40,21 @@ class RoadAlertService {
|
||||||
}
|
}
|
||||||
|
|
||||||
void _initTts() {
|
void _initTts() {
|
||||||
_tts.setLanguage("en-US");
|
try {
|
||||||
_tts.setSpeechRate(0.5);
|
_tts.setLanguage("en-US");
|
||||||
_tts.setVolume(1.0);
|
_tts.setSpeechRate(0.5);
|
||||||
|
_tts.setVolume(1.0);
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint('[RoadAlerts] TTS init warning: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _startListening() {
|
void _startListening() {
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint('[RoadAlerts] Service initialized');
|
||||||
|
}
|
||||||
_locationSub = _ref.read(locationServiceProvider).getFilteredPositionStream().listen((position) {
|
_locationSub = _ref.read(locationServiceProvider).getFilteredPositionStream().listen((position) {
|
||||||
_processPosition(position);
|
_processPosition(position);
|
||||||
});
|
});
|
||||||
|
|
@ -51,8 +63,10 @@ class RoadAlertService {
|
||||||
Future<void> _processPosition(Position position) async {
|
Future<void> _processPosition(Position position) async {
|
||||||
final isEnabled = _ref.read(roadAlertsEnabledProvider);
|
final isEnabled = _ref.read(roadAlertsEnabledProvider);
|
||||||
if (!isEnabled) {
|
if (!isEnabled) {
|
||||||
_cachedAlerts = [];
|
if (_cachedAlerts.isNotEmpty) {
|
||||||
_alertsController.add([]);
|
_cachedAlerts = [];
|
||||||
|
_alertsController.add([]);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -63,42 +77,48 @@ class RoadAlertService {
|
||||||
final tomtomApiKey = _ref.read(tomtomApiKeyProvider);
|
final tomtomApiKey = _ref.read(tomtomApiKeyProvider);
|
||||||
final communityApiKey = _ref.read(communityApiKeyProvider);
|
final communityApiKey = _ref.read(communityApiKeyProvider);
|
||||||
|
|
||||||
// Throttle fetches: query every 45s OR when moved > 500m
|
// Request serialization check: prevent overlapping queries
|
||||||
bool shouldFetch = false;
|
bool shouldFetch = false;
|
||||||
if (_lastFetchTime == null || now.difference(_lastFetchTime!).inSeconds >= 45) {
|
if (!_isFetching) {
|
||||||
shouldFetch = true;
|
if (_lastFetchTime == null || now.difference(_lastFetchTime!).inSeconds >= 45) {
|
||||||
} else if (_lastFetchPosition != null) {
|
|
||||||
final dist = Geolocator.distanceBetween(
|
|
||||||
_lastFetchPosition!.latitude,
|
|
||||||
_lastFetchPosition!.longitude,
|
|
||||||
position.latitude,
|
|
||||||
position.longitude,
|
|
||||||
);
|
|
||||||
if (dist > 500.0) {
|
|
||||||
shouldFetch = true;
|
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) {
|
if (shouldFetch) {
|
||||||
|
_isFetching = true;
|
||||||
_lastFetchTime = now;
|
_lastFetchTime = now;
|
||||||
_lastFetchPosition = position;
|
_lastFetchPosition = position;
|
||||||
await _fetchAlertsFromProviders(
|
try {
|
||||||
position.latitude,
|
await _fetchAlertsFromProviders(
|
||||||
position.longitude,
|
position.latitude,
|
||||||
radiusMeters,
|
position.longitude,
|
||||||
tomtomApiKey: tomtomApiKey,
|
radiusMeters,
|
||||||
communityApiKey: communityApiKey,
|
tomtomApiKey: tomtomApiKey,
|
||||||
);
|
communityApiKey: communityApiKey,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
_isFetching = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Process 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)) {
|
if (!enabledCategories.contains(alert.category)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -115,12 +135,16 @@ class RoadAlertService {
|
||||||
final key = '${alert.providerId}_${alert.id}';
|
final key = '${alert.providerId}_${alert.id}';
|
||||||
deduplicated[key] = updatedAlert;
|
deduplicated[key] = updatedAlert;
|
||||||
|
|
||||||
// Approach Audio Warning: check distance (< 400m), cooldown, and direction relevance
|
// Approach Audio Warning with 4-second speech cooldown
|
||||||
if (audioEnabled && dist <= 400.0 && !_announcedAlertIds.contains(key)) {
|
if (audioEnabled && dist <= 400.0 && !_announcedAlertIds.contains(key)) {
|
||||||
final isAhead = _isAlertAheadOfRider(position, alert);
|
final isAhead = _isAlertAheadOfRider(position, alert);
|
||||||
if (isAhead) {
|
if (isAhead) {
|
||||||
_announcedAlertIds.add(key);
|
final speechTime = DateTime.now();
|
||||||
_announceAlertAudio(updatedAlert);
|
if (_lastSpeechTime == null || speechTime.difference(_lastSpeechTime!).inSeconds >= 4) {
|
||||||
|
_lastSpeechTime = speechTime;
|
||||||
|
_announcedAlertIds.add(key);
|
||||||
|
_announceAlertAudio(updatedAlert);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -134,7 +158,6 @@ class RoadAlertService {
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _isAlertAheadOfRider(Position userPos, RoadAlert alert) {
|
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;
|
if (userPos.speed < 2.0) return true;
|
||||||
|
|
||||||
final riderHeading = userPos.heading;
|
final riderHeading = userPos.heading;
|
||||||
|
|
@ -148,7 +171,6 @@ class RoadAlertService {
|
||||||
double delta = (bearingToAlert - riderHeading).abs();
|
double delta = (bearingToAlert - riderHeading).abs();
|
||||||
if (delta > 180) delta = 360 - delta;
|
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;
|
return delta <= 100.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -162,6 +184,10 @@ class RoadAlertService {
|
||||||
final providers = _ref.read(activeRoadAlertProviders);
|
final providers = _ref.read(activeRoadAlertProviders);
|
||||||
final List<RoadAlert> newAlerts = [];
|
final List<RoadAlert> newAlerts = [];
|
||||||
|
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint('[RoadAlerts] Fetching alerts from ${providers.length} providers...');
|
||||||
|
}
|
||||||
|
|
||||||
for (var provider in providers) {
|
for (var provider in providers) {
|
||||||
try {
|
try {
|
||||||
String? key;
|
String? key;
|
||||||
|
|
@ -175,10 +201,17 @@ class RoadAlertService {
|
||||||
apiKey: key,
|
apiKey: key,
|
||||||
);
|
);
|
||||||
newAlerts.addAll(fetched);
|
newAlerts.addAll(fetched);
|
||||||
} catch (_) {}
|
} catch (e) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint('[RoadAlerts] Provider ${provider.id} error: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_cachedAlerts = newAlerts;
|
_cachedAlerts = newAlerts;
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint('[RoadAlerts] Active cached alerts count: ${_cachedAlerts.length}');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _announceAlertAudio(RoadAlert alert) {
|
void _announceAlertAudio(RoadAlert alert) {
|
||||||
|
|
@ -209,12 +242,28 @@ class RoadAlertService {
|
||||||
phrase = "Traffic alert ahead.";
|
phrase = "Traffic alert ahead.";
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
_tts.speak(phrase);
|
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint('[RoadAlerts] Announcing audio alert: $phrase');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
_tts.speak(phrase);
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint('[RoadAlerts] TTS speak error: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint('[RoadAlerts] Disposing service resources');
|
||||||
|
}
|
||||||
_locationSub?.cancel();
|
_locationSub?.cancel();
|
||||||
_alertsController.close();
|
_alertsController.close();
|
||||||
_tts.stop();
|
try {
|
||||||
|
_tts.stop();
|
||||||
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'road_alert_models.dart';
|
import 'road_alert_models.dart';
|
||||||
import 'road_alert_provider_interface.dart';
|
import 'road_alert_provider_interface.dart';
|
||||||
|
|
@ -26,7 +27,6 @@ class TomTomRoadAlertProvider implements RoadAlertProvider {
|
||||||
return RoadAlertProviderStatus.notConfigured;
|
return RoadAlertProviderStatus.notConfigured;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
// Test query using a small bounding box over San Francisco
|
|
||||||
final url = Uri.parse(
|
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');
|
'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));
|
final response = await http.get(url).timeout(const Duration(seconds: 6));
|
||||||
|
|
@ -55,14 +55,13 @@ class TomTomRoadAlertProvider implements RoadAlertProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Convert radius to bounding box offset (~111,000m per degree of latitude)
|
|
||||||
final latOffset = radiusMeters / 111000.0;
|
final latOffset = radiusMeters / 111000.0;
|
||||||
final lngOffset = radiusMeters / (111000.0 * math.cos(latitude * math.pi / 180.0));
|
final lngOffset = radiusMeters / (111000.0 * math.cos(latitude * math.pi / 180.0));
|
||||||
|
|
||||||
final minLng = longitude - lngOffset;
|
final minLng = (longitude - lngOffset).clamp(-180.0, 180.0);
|
||||||
final minLat = latitude - latOffset;
|
final minLat = (latitude - latOffset).clamp(-85.0, 85.0);
|
||||||
final maxLng = longitude + lngOffset;
|
final maxLng = (longitude + lngOffset).clamp(-180.0, 180.0);
|
||||||
final maxLat = latitude + latOffset;
|
final maxLat = (latitude + latOffset).clamp(-85.0, 85.0);
|
||||||
|
|
||||||
final bboxStr = '${minLng.toStringAsFixed(4)},${minLat.toStringAsFixed(4)},${maxLng.toStringAsFixed(4)},${maxLat.toStringAsFixed(4)}';
|
final bboxStr = '${minLng.toStringAsFixed(4)},${minLat.toStringAsFixed(4)},${maxLng.toStringAsFixed(4)},${maxLat.toStringAsFixed(4)}';
|
||||||
|
|
||||||
|
|
@ -98,13 +97,13 @@ class TomTomRoadAlertProvider implements RoadAlertProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
final iconCategory = (props['iconCategory'] as num?)?.toInt() ?? 0;
|
final iconCategory = (props['iconCategory'] as num?)?.toInt() ?? 0;
|
||||||
final RoadAlertCategory category = _mapTomTomIconCategory(iconCategory);
|
final category = _mapTomTomIconCategory(iconCategory);
|
||||||
|
|
||||||
final description = (props['events'] != null && (props['events'] as List).isNotEmpty)
|
final description = (props['events'] != null && (props['events'] as List).isNotEmpty)
|
||||||
? props['events'][0]['description'] ?? 'Traffic incident'
|
? props['events'][0]['description'] ?? 'Traffic incident'
|
||||||
: (props['iconCategory'] != null ? 'Incident category $iconCategory' : 'Road Incident');
|
: 'Road Incident';
|
||||||
|
|
||||||
final alertId = props['id']?.toString() ?? '${incLat}_${incLng}';
|
final alertId = props['id']?.toString() ?? '${incLat}_$incLng';
|
||||||
|
|
||||||
alerts.add(RoadAlert(
|
alerts.add(RoadAlert(
|
||||||
id: alertId,
|
id: alertId,
|
||||||
|
|
@ -123,29 +122,36 @@ class TomTomRoadAlertProvider implements RoadAlertProvider {
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint('[RoadAlerts] TomTom returned ${alerts.length} incidents');
|
||||||
|
}
|
||||||
|
|
||||||
return alerts;
|
return alerts;
|
||||||
} catch (_) {
|
} catch (e) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint('[RoadAlerts] TomTom fetch error: $e');
|
||||||
|
}
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
RoadAlertCategory _mapTomTomIconCategory(int iconCategory) {
|
RoadAlertCategory _mapTomTomIconCategory(int iconCategory) {
|
||||||
switch (iconCategory) {
|
switch (iconCategory) {
|
||||||
case 1: // Accident
|
case 1:
|
||||||
return RoadAlertCategory.accident;
|
return RoadAlertCategory.accident;
|
||||||
case 2: // Fog / Hazard
|
case 2:
|
||||||
case 3: // Dangerous Conditions
|
case 3:
|
||||||
case 4: // Rain / Ice / Snow
|
case 4:
|
||||||
case 5: // Wind / Weather
|
case 5:
|
||||||
return RoadAlertCategory.hazard;
|
return RoadAlertCategory.hazard;
|
||||||
case 6: // Lane restriction / Construction
|
case 6:
|
||||||
case 7: // Roadworks
|
case 7:
|
||||||
return RoadAlertCategory.construction;
|
return RoadAlertCategory.construction;
|
||||||
case 8: // Road closed
|
case 8:
|
||||||
case 9: // Blocked road
|
case 9:
|
||||||
return RoadAlertCategory.roadClosure;
|
return RoadAlertCategory.roadClosure;
|
||||||
case 10: // Traffic Jam
|
case 10:
|
||||||
case 11: // Slow traffic
|
case 11:
|
||||||
return RoadAlertCategory.traffic;
|
return RoadAlertCategory.traffic;
|
||||||
default:
|
default:
|
||||||
return RoadAlertCategory.hazard;
|
return RoadAlertCategory.hazard;
|
||||||
|
|
|
||||||
|
|
@ -46,8 +46,9 @@ class OpenStreetMapProvider implements AppMapProvider {
|
||||||
if (hasGesture) {
|
if (hasGesture) {
|
||||||
onUserInteraction?.call();
|
onUserInteraction?.call();
|
||||||
}
|
}
|
||||||
if (position.center != null && onPositionChanged != null) {
|
if (onPositionChanged != null) {
|
||||||
onPositionChanged(AppLatLng(position.center!.latitude, position.center!.longitude));
|
final c = position.center;
|
||||||
|
onPositionChanged(AppLatLng(c.latitude, c.longitude));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -534,7 +534,7 @@ packages:
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.15.6"
|
version: "0.15.6"
|
||||||
http:
|
http:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: http
|
name: http
|
||||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ dependencies:
|
||||||
rxdart: ^0.28.0
|
rxdart: ^0.28.0
|
||||||
flutter_tts: ^4.2.5
|
flutter_tts: ^4.2.5
|
||||||
share_plus: ^13.3.0
|
share_plus: ^13.3.0
|
||||||
|
http: ^1.3.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,222 @@
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:geolocator/geolocator.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.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/tomtom_road_alert_provider.dart';
|
||||||
|
import 'package:motorcycle_dash/services/alerts/mock_road_alert_provider.dart';
|
||||||
|
import 'package:motorcycle_dash/services/alerts/road_alert_providers.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Production Hardening Tests', () {
|
||||||
|
// ─── Distance Calculation ────────────────────────────────────────────────
|
||||||
|
test('Distance calculation meters vs miles - no unit confusion', () {
|
||||||
|
// 1 mile is ~1609 meters
|
||||||
|
final distMeters = Geolocator.distanceBetween(
|
||||||
|
37.7749, -122.4194, // SF
|
||||||
|
37.7891, -122.4194, // ~1 mile north
|
||||||
|
);
|
||||||
|
expect(distMeters, greaterThan(1500.0)); // must be in meters
|
||||||
|
expect(distMeters, lessThan(1800.0));
|
||||||
|
|
||||||
|
// Convert to miles - must NOT be near raw meter value
|
||||||
|
final distMiles = distMeters * 0.000621371;
|
||||||
|
expect(distMiles, greaterThan(0.9));
|
||||||
|
expect(distMiles, lessThan(1.2));
|
||||||
|
expect(distMiles, isNot(closeTo(distMeters, 1.0)));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Bounding Box Clamping ────────────────────────────────────────────────
|
||||||
|
test('TomTom provider does not generate invalid bounding box near poles', () {
|
||||||
|
final provider = TomTomRoadAlertProvider();
|
||||||
|
// Near north pole - must not throw, must clamp latitudes
|
||||||
|
expect(() async => await provider.getAlerts(
|
||||||
|
latitude: 84.9,
|
||||||
|
longitude: 0.0,
|
||||||
|
radiusMeters: 500.0,
|
||||||
|
), returnsNormally);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Alert Expiration ─────────────────────────────────────────────────────
|
||||||
|
test('Expired alerts are not shown as current', () {
|
||||||
|
final now = DateTime.now();
|
||||||
|
|
||||||
|
final expiredAlert = RoadAlert(
|
||||||
|
id: 'exp_1',
|
||||||
|
providerId: 'test',
|
||||||
|
category: RoadAlertCategory.hazard,
|
||||||
|
latitude: 37.7749,
|
||||||
|
longitude: -122.4194,
|
||||||
|
description: 'This alert has expired',
|
||||||
|
reportedAt: now.subtract(const Duration(hours: 2)),
|
||||||
|
expiresAt: now.subtract(const Duration(minutes: 30)),
|
||||||
|
lastUpdated: now.subtract(const Duration(hours: 2)),
|
||||||
|
source: 'Test',
|
||||||
|
);
|
||||||
|
|
||||||
|
final liveAlert = RoadAlert(
|
||||||
|
id: 'live_1',
|
||||||
|
providerId: 'test',
|
||||||
|
category: RoadAlertCategory.police,
|
||||||
|
latitude: 37.7750,
|
||||||
|
longitude: -122.4195,
|
||||||
|
description: 'This alert is live',
|
||||||
|
reportedAt: now.subtract(const Duration(minutes: 5)),
|
||||||
|
expiresAt: now.add(const Duration(minutes: 25)),
|
||||||
|
lastUpdated: now,
|
||||||
|
source: 'Test',
|
||||||
|
);
|
||||||
|
|
||||||
|
final alerts = [expiredAlert, liveAlert];
|
||||||
|
final activeAlerts = alerts.where((a) =>
|
||||||
|
a.expiresAt == null || a.expiresAt!.isAfter(now)).toList();
|
||||||
|
|
||||||
|
expect(activeAlerts.length, equals(1));
|
||||||
|
expect(activeAlerts.first.id, equals('live_1'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Deduplication ────────────────────────────────────────────────────────
|
||||||
|
test('Alerts are deduplicated by providerId + id', () {
|
||||||
|
final now = DateTime.now();
|
||||||
|
|
||||||
|
RoadAlert makeAlert(String id) => RoadAlert(
|
||||||
|
id: id,
|
||||||
|
providerId: 'test_provider',
|
||||||
|
category: RoadAlertCategory.accident,
|
||||||
|
latitude: 37.7749,
|
||||||
|
longitude: -122.4194,
|
||||||
|
description: 'Accident',
|
||||||
|
reportedAt: now,
|
||||||
|
expiresAt: now.add(const Duration(hours: 1)),
|
||||||
|
lastUpdated: now,
|
||||||
|
source: 'Test',
|
||||||
|
);
|
||||||
|
|
||||||
|
final alerts = [
|
||||||
|
makeAlert('acc_1'),
|
||||||
|
makeAlert('acc_1'), // duplicate
|
||||||
|
makeAlert('acc_2'),
|
||||||
|
];
|
||||||
|
|
||||||
|
final deduplicated = <String, RoadAlert>{};
|
||||||
|
for (var a in alerts) {
|
||||||
|
final key = '${a.providerId}_${a.id}';
|
||||||
|
deduplicated[key] = a;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(deduplicated.length, equals(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Category Parsing ─────────────────────────────────────────────────────
|
||||||
|
test('CommunityTrafficParser maps all police subtypes correctly', () {
|
||||||
|
final inputs = ['POLICE', 'POLICE_VISIBLE', 'POLICE_HIDDEN', 'POLICE_TRAP'];
|
||||||
|
for (final subtype in inputs) {
|
||||||
|
final cat = CommunityTrafficParser.mapTypeToCategory('POLICE', subtype);
|
||||||
|
expect(cat, equals(RoadAlertCategory.police),
|
||||||
|
reason: 'Expected police for subtype $subtype');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CommunityTrafficParser maps speed camera types correctly', () {
|
||||||
|
expect(CommunityTrafficParser.mapTypeToCategory('SPEED_CAM', ''),
|
||||||
|
equals(RoadAlertCategory.speedCamera));
|
||||||
|
expect(CommunityTrafficParser.mapTypeToCategory('', 'RED_LIGHT_CAMERA'),
|
||||||
|
equals(RoadAlertCategory.speedCamera));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CommunityTrafficParser handles unknown type as other', () {
|
||||||
|
final cat = CommunityTrafficParser.mapTypeToCategory('UNKNOWN_FUTURE_TYPE', '');
|
||||||
|
// Should not throw - should fall back to 'other'
|
||||||
|
expect(cat, isNotNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CommunityTrafficParser skips alerts with missing coordinates', () {
|
||||||
|
final jsonMissingCoords = {
|
||||||
|
'alerts': [
|
||||||
|
{'id': 'no_coords', 'type': 'HAZARD', 'subtype': ''},
|
||||||
|
{'id': 'valid', 'type': 'ACCIDENT', 'subtype': '', 'latitude': 37.77, 'longitude': -122.41},
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
final alerts = CommunityTrafficParser.parseAlertsJson(
|
||||||
|
jsonMissingCoords,
|
||||||
|
providerId: 'test',
|
||||||
|
providerName: 'Test',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(alerts.length, equals(1));
|
||||||
|
expect(alerts.first.id, equals('valid'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Provider Capabilities ────────────────────────────────────────────────
|
||||||
|
test('TomTom does NOT advertise police or speed camera categories', () {
|
||||||
|
final provider = TomTomRoadAlertProvider();
|
||||||
|
expect(provider.supportedCategories, isNot(contains(RoadAlertCategory.police)));
|
||||||
|
expect(provider.supportedCategories, isNot(contains(RoadAlertCategory.speedCamera)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Mock provider returns empty in non-debug mode', () async {
|
||||||
|
// This test can only verify in the current build mode
|
||||||
|
final mockProvider = MockRoadAlertProvider();
|
||||||
|
if (!kDebugMode) {
|
||||||
|
final alerts = await mockProvider.getAlerts(
|
||||||
|
latitude: 37.7749,
|
||||||
|
longitude: -122.4194,
|
||||||
|
radiusMeters: 1000.0,
|
||||||
|
);
|
||||||
|
expect(alerts.isEmpty, isTrue,
|
||||||
|
reason: 'Mock provider must not expose alerts in release mode');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Riverpod Settings Defaults ───────────────────────────────────────────
|
||||||
|
test('Road alerts settings have safe 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(communityApiKeyProvider), isEmpty);
|
||||||
|
expect(container.read(tomtomStatusProvider),
|
||||||
|
equals(RoadAlertProviderStatus.notConfigured));
|
||||||
|
expect(container.read(communityStatusProvider),
|
||||||
|
equals(RoadAlertProviderStatus.notConfigured));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Active providers list type is correct', () {
|
||||||
|
// Just verify the provider definition is sane without reading SharedPreferences.
|
||||||
|
// Full integration is verified on-device.
|
||||||
|
expect(activeRoadAlertProviders, isNotNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Alert Radius Conversions ─────────────────────────────────────────────
|
||||||
|
test('Alert radius values match documented distances', () {
|
||||||
|
const ft500 = 152.4; // 500 feet in meters
|
||||||
|
const ft1000 = 304.8;
|
||||||
|
const quarterMile = 402.3;
|
||||||
|
const halfMile = 804.7;
|
||||||
|
const oneMile = 1609.3;
|
||||||
|
|
||||||
|
// Verify none are equal (distinct values)
|
||||||
|
final radii = [ft500, ft1000, quarterMile, halfMile, oneMile];
|
||||||
|
final unique = radii.toSet();
|
||||||
|
expect(unique.length, equals(5));
|
||||||
|
|
||||||
|
// Verify ordering
|
||||||
|
expect(ft500, lessThan(ft1000));
|
||||||
|
expect(ft1000, lessThan(quarterMile));
|
||||||
|
expect(quarterMile, lessThan(halfMile));
|
||||||
|
expect(halfMile, lessThan(oneMile));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue