fix(alerts): decouple tactical map horizon, add diagnostics HUD, and trace Zyla pipeline

This commit is contained in:
Developer 2026-10-13 14:47:30 -04:00
parent 5fcaafbe36
commit ad99da10ba
11 changed files with 1507 additions and 55 deletions

View File

@ -77,6 +77,11 @@ class _MapScreenState extends ConsumerState<MapScreen> {
_isLoadingHistory = false;
_initLiveLocation();
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
ref.read(roadAlertServiceProvider).refreshAlerts(force: true);
}
});
}
@override
@ -1087,9 +1092,15 @@ class _MapScreenState extends ConsumerState<MapScreen> {
),
);
final List<AppMapMarker> mapMarkers = [motorcycleMarker];
final liveAlerts = layerSettings.showPoliceTraps
? (liveAlertsAsync.value ?? [])
: <RoadAlert>[];
final allLiveAlerts = liveAlertsAsync.value ?? [];
final liveAlerts = allLiveAlerts.where((alert) {
if (!layerSettings.showPoliceTraps &&
(alert.category == RoadAlertCategory.police ||
alert.category == RoadAlertCategory.speedCamera)) {
return false;
}
return true;
}).toList();
for (var alert in liveAlerts) {
mapMarkers.add(
AppMapMarker(

View File

@ -0,0 +1,655 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import '../../../core/theme/theme_extensions.dart';
import '../../../services/alerts/road_alert_models.dart';
import '../../../services/alerts/road_alert_providers.dart';
import '../../../services/alerts/road_alert_service.dart';
/// Road Alert Diagnostics Modal Bottom Sheet.
/// Displays live pipeline telemetry, provider connection health, stage-by-stage
/// report ingestion and rejection funnels, and quota compliance.
class RoadAlertDiagnosticsSheet extends ConsumerStatefulWidget {
const RoadAlertDiagnosticsSheet({super.key});
static Future<void> show(BuildContext context) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => const RoadAlertDiagnosticsSheet(),
);
}
@override
ConsumerState<RoadAlertDiagnosticsSheet> createState() =>
_RoadAlertDiagnosticsSheetState();
}
class _RoadAlertDiagnosticsSheetState
extends ConsumerState<RoadAlertDiagnosticsSheet> {
bool _isRefreshing = false;
bool _isTestingConnection = false;
Future<void> _handleForcePoll() async {
setState(() => _isRefreshing = true);
try {
await ref.read(roadAlertServiceProvider).refreshAlerts(force: true);
} catch (_) {}
if (mounted) {
setState(() => _isRefreshing = false);
}
}
Future<void> _handleTestConnection() async {
setState(() => _isTestingConnection = true);
try {
final provider = ref.read(zylaProviderInstance);
final status = await provider.testConnection('');
ref.read(zylaStatusProvider.notifier).state = status;
} catch (_) {}
if (mounted) {
setState(() => _isTestingConnection = false);
}
}
@override
Widget build(BuildContext context) {
final colors = context.motorColors;
final theme = Theme.of(context);
final diagnostics = ref.watch(roadAlertDiagnosticsProvider);
final media = MediaQuery.of(context);
final maxHeight = media.size.height * 0.90;
return Container(
key: const ValueKey('road_alert_diagnostics_sheet'),
constraints: BoxConstraints(maxHeight: maxHeight),
decoration: BoxDecoration(
color: colors.surfaceContainer,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
border: Border.all(
color: colors.outlineVariant.withValues(alpha: 0.8),
width: 1.0,
),
boxShadow: const [
BoxShadow(
color: Colors.black87,
blurRadius: 20,
offset: Offset(0, -4),
),
],
),
child: SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Drag handle pill
Center(
child: Container(
margin: const EdgeInsets.only(top: 10, bottom: 6),
width: 38,
height: 4,
decoration: BoxDecoration(
color: colors.outlineVariant,
borderRadius: BorderRadius.circular(2),
),
),
),
// Header
Padding(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 8),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: colors.cyberCyan.withValues(alpha: 0.15),
shape: BoxShape.circle,
),
child: Icon(
Icons.monitor_heart_outlined,
size: 20,
color: colors.cyberCyan,
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'ROAD ALERT DIAGNOSTICS',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
fontWeight: FontWeight.w800,
letterSpacing: 0.8,
color: theme.colorScheme.onSurface,
),
),
const SizedBox(width: 8),
_buildModeBadge(diagnostics.isLive, colors),
],
),
Text(
'Zyla Waze Data #4814 • Backend Gateway Pipeline',
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
const Divider(height: 1, thickness: 0.8),
// Scrollable Content
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Status and Provider Health Banner
_buildStatusCard(diagnostics, colors, theme),
const SizedBox(height: 12),
// Error / Quota Notice Banner (if any)
if (diagnostics.lastError != null ||
diagnostics.hasAuthError ||
diagnostics.hasQuotaError) ...[
_buildErrorBanner(diagnostics, colors),
const SizedBox(height: 12),
],
// Geographic & Telemetry Metadata
_buildGeoTelemetryCard(diagnostics, colors, theme),
const SizedBox(height: 16),
// Pipeline Stage Funnel Header
Text(
'PIPELINE STAGE METRICS',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 11,
fontWeight: FontWeight.w700,
letterSpacing: 1.0,
color: colors.cyberCyan,
),
),
const SizedBox(height: 8),
// Stage Funnel Grid
_buildStageMetricsGrid(diagnostics, colors, theme),
const SizedBox(height: 16),
// Rejection Breakdown Detail Card
_buildRejectionBreakdownCard(diagnostics, colors, theme),
const SizedBox(height: 20),
// Action Controls
Row(
children: [
Expanded(
child: OutlinedButton.icon(
key: const ValueKey('diag_test_connection_btn'),
onPressed: _isTestingConnection
? null
: _handleTestConnection,
style: OutlinedButton.styleFrom(
foregroundColor: theme.colorScheme.onSurface,
side: BorderSide(color: colors.outlineVariant),
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
icon: _isTestingConnection
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.sync, size: 16),
label: const Text(
'Test Gateway',
style: TextStyle(fontSize: 12.5),
),
),
),
const SizedBox(width: 10),
Expanded(
child: FilledButton.icon(
key: const ValueKey('diag_force_poll_btn'),
onPressed: _isRefreshing ? null : _handleForcePoll,
style: FilledButton.styleFrom(
backgroundColor: colors.cyberCyan,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
icon: _isRefreshing
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.black,
),
)
: const Icon(Icons.refresh, size: 16),
label: const Text(
'Force Poll Now',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 13,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
],
),
),
),
],
),
),
);
}
Widget _buildModeBadge(bool isLive, dynamic colors) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1.5),
decoration: BoxDecoration(
color: isLive
? colors.launchGreen.withValues(alpha: 0.15)
: Colors.purpleAccent.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: isLive
? colors.launchGreen.withValues(alpha: 0.4)
: Colors.purpleAccent.withValues(alpha: 0.4),
width: 0.8,
),
),
child: Text(
isLive ? 'LIVE' : 'FIXTURE',
style: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 9.5,
fontWeight: FontWeight.bold,
color: isLive ? colors.launchGreen : Colors.purpleAccent,
),
),
);
}
Widget _buildStatusCard(
RoadAlertDiagnostics diag, dynamic colors, ThemeData theme) {
Color statusColor;
String statusText;
IconData statusIcon;
switch (diag.providerStatus) {
case RoadAlertProviderStatus.connected:
statusColor = colors.launchGreen;
statusText = 'Operational & Connected';
statusIcon = Icons.check_circle_outline;
break;
case RoadAlertProviderStatus.rateLimited:
statusColor = colors.warningAmber;
statusText = 'Rate Limited / Quota Exhausted';
statusIcon = Icons.timelapse;
break;
case RoadAlertProviderStatus.authError:
statusColor = colors.raceRed;
statusText = 'Authentication Required / Token Expired';
statusIcon = Icons.lock_outline;
break;
case RoadAlertProviderStatus.networkError:
statusColor = colors.raceRed;
statusText = 'Gateway Offline / Network Error';
statusIcon = Icons.cloud_off;
break;
case RoadAlertProviderStatus.configured:
statusColor = colors.cyberCyan;
statusText = 'Configured';
statusIcon = Icons.settings;
break;
case RoadAlertProviderStatus.notConfigured:
statusColor = colors.outlineVariant;
statusText = 'Provider Disabled / Not Configured';
statusIcon = Icons.radio_button_unchecked;
break;
}
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colors.surfaceContainerHighest.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: statusColor.withValues(alpha: 0.4)),
),
child: Row(
children: [
Icon(statusIcon, color: statusColor, size: 22),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
statusText,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 13,
fontWeight: FontWeight.bold,
color: statusColor,
),
),
Text(
diag.statusMessage ?? 'Gateway communicating cleanly.',
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
],
),
);
}
Widget _buildErrorBanner(RoadAlertDiagnostics diag, dynamic colors) {
String errorMsg = diag.lastError ?? '';
if (diag.hasAuthError && errorMsg.isEmpty) {
errorMsg = 'Firebase token missing or expired. Sign in with Google / Email.';
} else if (diag.hasQuotaError && errorMsg.isEmpty) {
errorMsg = 'Upstream 500-request monthly budget limit reached.';
}
return Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: colors.raceRed.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: colors.raceRed.withValues(alpha: 0.3)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.warning_amber_rounded, size: 18, color: colors.raceRed),
const SizedBox(width: 8),
Expanded(
child: Text(
errorMsg,
style: TextStyle(
fontSize: 11.5,
color: colors.raceRed,
height: 1.3,
),
),
),
],
),
);
}
Widget _buildGeoTelemetryCard(
RoadAlertDiagnostics diag, dynamic colors, ThemeData theme) {
final lastFetchStr = diag.lastFetchTime != null
? DateFormat('HH:mm:ss').format(diag.lastFetchTime!)
: 'Never';
final lastSuccessStr = diag.lastSuccessfulFetchTime != null
? DateFormat('HH:mm:ss').format(diag.lastSuccessfulFetchTime!)
: 'Never';
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colors.surfaceContainerHighest.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: colors.outlineVariant.withValues(alpha: 0.6)),
),
child: Column(
children: [
_buildInfoRow(
'Requested Area',
diag.lastRequestedArea ?? 'No coordinate fix yet',
theme,
),
const Divider(height: 14, thickness: 0.6),
_buildInfoRow('Last Request Attempt', lastFetchStr, theme),
const Divider(height: 14, thickness: 0.6),
_buildInfoRow('Last Successful Live Response', lastSuccessStr, theme),
],
),
);
}
Widget _buildInfoRow(String label, String value, ThemeData theme) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: TextStyle(
fontSize: 11.5,
color: theme.colorScheme.onSurfaceVariant,
),
),
Flexible(
child: Text(
value,
textAlign: TextAlign.right,
style: const TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 11.5,
fontWeight: FontWeight.w600,
),
),
),
],
);
}
Widget _buildStageMetricsGrid(
RoadAlertDiagnostics diag, dynamic colors, ThemeData theme) {
return Row(
children: [
Expanded(
child: _buildMetricTile(
label: 'Raw Upstream',
value: diag.rawReportsCount.toString(),
color: colors.cyberCyan,
icon: Icons.cloud_download_outlined,
),
),
const SizedBox(width: 8),
Expanded(
child: _buildMetricTile(
label: 'Normalized',
value: diag.normalizedAcceptedCount.toString(),
color: Colors.lightBlueAccent,
icon: Icons.check_circle_outline,
),
),
const SizedBox(width: 8),
Expanded(
child: _buildMetricTile(
label: 'On Map HUD',
value: diag.displayedOnMapCount.toString(),
color: colors.launchGreen,
icon: Icons.map_outlined,
),
),
const SizedBox(width: 8),
Expanded(
child: _buildMetricTile(
label: 'Audio Warning',
value: diag.audioCandidateCount.toString(),
color: colors.warningAmber,
icon: Icons.volume_up_outlined,
),
),
],
);
}
Widget _buildMetricTile({
required String label,
required String value,
required Color color,
required IconData icon,
}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: color.withValues(alpha: 0.3)),
),
child: Column(
children: [
Icon(icon, size: 16, color: color),
const SizedBox(height: 4),
Text(
value,
style: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 16,
fontWeight: FontWeight.bold,
color: color,
),
),
const SizedBox(height: 2),
Text(
label,
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 9.5,
fontWeight: FontWeight.w500,
),
),
],
),
);
}
Widget _buildRejectionBreakdownCard(
RoadAlertDiagnostics diag, dynamic colors, ThemeData theme) {
final reasons = diag.rejectedReasons;
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colors.surfaceContainerHighest.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: colors.outlineVariant.withValues(alpha: 0.6)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'FILTERED & REJECTED BREAKDOWN',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 10.5,
fontWeight: FontWeight.w700,
letterSpacing: 0.6,
color: colors.warningAmber,
),
),
Text(
'Total: ${diag.totalRejectedCount}',
style: const TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 10),
if (reasons.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Text(
'No alerts rejected in the last cycle.',
style: TextStyle(
fontSize: 11,
fontStyle: FontStyle.italic,
color: theme.colorScheme.onSurfaceVariant,
),
),
)
else ...[
_buildReasonRow('Stale or Expired TTL', reasons['stale_or_expired'] ?? 0, colors),
_buildReasonRow('Category Filter Disabled', reasons['category_disabled'] ?? 0, colors),
_buildReasonRow('Out of Tactical Horizon (>25km)', reasons['out_of_tactical_range'] ?? 0, colors),
_buildReasonRow('Multi-Source Deduplicated', reasons['deduplicated'] ?? 0, colors),
_buildReasonRow('Jurisdiction Compliance Suppressed', reasons['compliance_suppressed'] ?? 0, colors),
_buildReasonRow('Manually Dismissed by Rider', reasons['dismissed'] ?? 0, colors),
],
],
),
);
}
Widget _buildReasonRow(String title, int count, dynamic colors) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
title,
style: const TextStyle(fontSize: 11),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: count > 0
? colors.warningAmber.withValues(alpha: 0.15)
: Colors.transparent,
borderRadius: BorderRadius.circular(4),
),
child: Text(
count.toString(),
style: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 11,
fontWeight: count > 0 ? FontWeight.bold : FontWeight.normal,
color: count > 0 ? colors.warningAmber : colors.outlineVariant,
),
),
),
],
),
);
}
}

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/theme/theme_extensions.dart';
import '../../../services/map/map_layer_settings.dart';
import 'road_alert_diagnostics_sheet.dart';
/// Stitch MAP-02: Tactical Layer Overrides Bottom Sheet Drawer.
///
@ -298,6 +299,56 @@ class TacticalLayersBottomSheet extends ConsumerWidget {
onChanged: notifier.toggleOfflineOnly,
),
const SizedBox(height: 14),
// Road Alert Pipeline Diagnostics Shortcut
InkWell(
key: const ValueKey('open_road_alert_diagnostics_button'),
borderRadius: BorderRadius.circular(10),
onTap: () {
Navigator.of(context).pop();
RoadAlertDiagnosticsSheet.show(context);
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: colors.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: colors.outlineVariant),
),
child: Row(
children: [
Icon(Icons.monitor_heart_outlined, color: colors.cyberCyan, size: 20),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Road Alert Diagnostics',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 13,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
),
Text(
'View live Zyla pipeline telemetry, ingestion funnel & rejection breakdown',
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
Icon(Icons.chevron_right, color: colors.outlineVariant, size: 20),
],
),
),
),
const SizedBox(height: 20),
// Section 3: Action Buttons

View File

@ -16,6 +16,7 @@ import '../../services/alerts/widgets/quick_swarm_report_modal.dart';
import '../../services/auth/auth_providers.dart';
import '../../services/field_test/field_test_logger.dart';
import '../../services/sensor_fusion_service.dart';
import '../map/widgets/road_alert_diagnostics_sheet.dart';
import 'widgets/current_plan_card.dart';
class SettingsScreen extends ConsumerStatefulWidget {
@ -1100,46 +1101,64 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
),
),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerRight,
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
minimumSize: const Size(140, 48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
Wrap(
alignment: WrapAlignment.end,
spacing: 8,
runSpacing: 8,
children: [
OutlinedButton.icon(
key: const ValueKey('view_zyla_diagnostics_button'),
style: OutlinedButton.styleFrom(
minimumSize: const Size(130, 44),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
onPressed: () {
RoadAlertDiagnosticsSheet.show(context);
},
icon: const Icon(Icons.analytics_outlined, size: 16),
label: const Text('Diagnostics HUD', style: TextStyle(fontSize: 12)),
),
onPressed: _isTestingZyla
? null
: () async {
setState(() => _isTestingZyla = true);
final messenger = ScaffoldMessenger.of(context);
final provider = ref.read(zylaProviderInstance);
final status = await provider.testConnection('');
ref.read(zylaStatusProvider.notifier).state = status;
setState(() => _isTestingZyla = false);
ElevatedButton.icon(
style: ElevatedButton.styleFrom(
minimumSize: const Size(130, 44),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
onPressed: _isTestingZyla
? null
: () async {
setState(() => _isTestingZyla = true);
final messenger = ScaffoldMessenger.of(context);
final provider = ref.read(zylaProviderInstance);
final status = await provider.testConnection('');
ref.read(zylaStatusProvider.notifier).state = status;
setState(() => _isTestingZyla = false);
if (mounted) {
messenger.showSnackBar(
SnackBar(
content: Text(
status == RoadAlertProviderStatus.connected
? 'Zyla adapter operational!'
: 'Zyla gateway status: ${status.name}',
if (mounted) {
messenger.showSnackBar(
SnackBar(
content: Text(
status == RoadAlertProviderStatus.connected
? 'Zyla adapter operational!'
: 'Zyla gateway status: ${status.name}',
),
),
),
);
}
},
icon: _isTestingZyla
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.sync, size: 18),
label: const Text('Test Connection'),
),
);
}
},
icon: _isTestingZyla
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.sync, size: 18),
label: const Text('Test Connection', style: TextStyle(fontSize: 12)),
),
],
),
],
),

View File

@ -0,0 +1,83 @@
import 'road_alert_models.dart';
/// Comprehensive diagnostics telemetry for the road alert pipeline.
/// Tracks upstream provider status, ingestion rates, stage-by-stage rejection reasons,
/// and map/audio dispatch metrics without leaking credentials or sensitive user data.
class RoadAlertDiagnostics {
final RoadAlertProviderStatus providerStatus;
final String? statusMessage;
final DateTime? lastFetchTime;
final DateTime? lastSuccessfulFetchTime;
final String? lastRequestedArea;
final double? lastRequestedRadiusMeters;
final bool isLive;
final int rawReportsCount;
final int normalizedAcceptedCount;
final Map<String, int> rejectedReasons;
final int displayedOnMapCount;
final int audioCandidateCount;
final String? lastError;
const RoadAlertDiagnostics({
this.providerStatus = RoadAlertProviderStatus.notConfigured,
this.statusMessage,
this.lastFetchTime,
this.lastSuccessfulFetchTime,
this.lastRequestedArea,
this.lastRequestedRadiusMeters,
this.isLive = true,
this.rawReportsCount = 0,
this.normalizedAcceptedCount = 0,
this.rejectedReasons = const {},
this.displayedOnMapCount = 0,
this.audioCandidateCount = 0,
this.lastError,
});
int get totalRejectedCount =>
rejectedReasons.values.fold(0, (sum, count) => sum + count);
bool get hasQuotaError =>
providerStatus == RoadAlertProviderStatus.rateLimited;
bool get hasAuthError =>
providerStatus == RoadAlertProviderStatus.authError;
bool get isOperational =>
providerStatus == RoadAlertProviderStatus.connected;
RoadAlertDiagnostics copyWith({
RoadAlertProviderStatus? providerStatus,
String? statusMessage,
DateTime? lastFetchTime,
DateTime? lastSuccessfulFetchTime,
String? lastRequestedArea,
double? lastRequestedRadiusMeters,
bool? isLive,
int? rawReportsCount,
int? normalizedAcceptedCount,
Map<String, int>? rejectedReasons,
int? displayedOnMapCount,
int? audioCandidateCount,
String? lastError,
}) {
return RoadAlertDiagnostics(
providerStatus: providerStatus ?? this.providerStatus,
statusMessage: statusMessage ?? this.statusMessage,
lastFetchTime: lastFetchTime ?? this.lastFetchTime,
lastSuccessfulFetchTime:
lastSuccessfulFetchTime ?? this.lastSuccessfulFetchTime,
lastRequestedArea: lastRequestedArea ?? this.lastRequestedArea,
lastRequestedRadiusMeters:
lastRequestedRadiusMeters ?? this.lastRequestedRadiusMeters,
isLive: isLive ?? this.isLive,
rawReportsCount: rawReportsCount ?? this.rawReportsCount,
normalizedAcceptedCount:
normalizedAcceptedCount ?? this.normalizedAcceptedCount,
rejectedReasons: rejectedReasons ?? this.rejectedReasons,
displayedOnMapCount: displayedOnMapCount ?? this.displayedOnMapCount,
audioCandidateCount: audioCandidateCount ?? this.audioCandidateCount,
lastError: lastError ?? this.lastError,
);
}
}

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
export 'road_alert_diagnostics.dart';
enum RoadAlertCategory {
police,

View File

@ -657,3 +657,45 @@ final jurisdictionComplianceEnabledProvider =
return JurisdictionComplianceEnabledNotifier();
});
/// Tactical map display horizon in meters (default 25 km / ~15.5 miles).
/// Decoupled from audio warning proximity radius.
class TacticalAlertRadiusMetersNotifier extends StateNotifier<double> {
static const double defaultRadius = 25000.0;
TacticalAlertRadiusMetersNotifier() : super(defaultRadius);
void setRadius(double radiusMeters) {
state = radiusMeters;
}
}
final tacticalAlertRadiusMetersProvider =
StateNotifierProvider<TacticalAlertRadiusMetersNotifier, double>((ref) {
return TacticalAlertRadiusMetersNotifier();
});
/// Live pipeline diagnostics state notifier
class RoadAlertDiagnosticsNotifier extends StateNotifier<RoadAlertDiagnostics> {
RoadAlertDiagnosticsNotifier() : super(const RoadAlertDiagnostics());
void updateDiagnostics(RoadAlertDiagnostics diagnostics) {
state = diagnostics;
}
void updateStatus({
required RoadAlertProviderStatus status,
String? message,
String? error,
}) {
state = state.copyWith(
providerStatus: status,
statusMessage: message,
lastError: error,
);
}
}
final roadAlertDiagnosticsProvider =
StateNotifierProvider<RoadAlertDiagnosticsNotifier, RoadAlertDiagnostics>((ref) {
return RoadAlertDiagnosticsNotifier();
});

View File

@ -40,6 +40,7 @@ class RoadAlertService {
StreamSubscription<double>? _leanSub;
DateTime? _lastFetchTime;
DateTime? _lastSuccessfulFetchTime;
Position? _lastFetchPosition;
Position? _lastObservedPosition;
bool _isFetching = false; // Request serialization lock
@ -55,6 +56,7 @@ class RoadAlertService {
Timer? _cleanupTimer;
Stream<List<RoadAlert>> get alertsStream => _alertsController.stream;
List<RoadAlert> get cachedAlerts => List.unmodifiable(_cachedAlerts);
double get currentLeanAngle => _currentLeanAngle;
bool get isCornering =>
_currentLeanAngle > 15.0 ||
@ -91,6 +93,20 @@ class RoadAlertService {
_processPosition(position);
});
// Proactive initial bootstrap from last known position
try {
final initialPos = _ref.read(locationServiceProvider).lastValidPosition;
if (initialPos != null) {
_processPosition(initialPos);
} else {
Geolocator.getLastKnownPosition().then((pos) {
if (pos != null && _lastObservedPosition == null) {
_processPosition(pos);
}
}).catchError((_) {});
}
} catch (_) {}
final stream = leanStream ?? (() {
try {
return _ref.read(sensorFusionServiceProvider).leanAngleStream;
@ -148,7 +164,49 @@ class RoadAlertService {
}
}
Future<void> _processPosition(Position position) async {
/// Public refresh method: allows MapScreen, routes, or user diagnostics
/// to force a live or localized alert refresh.
Future<void> refreshAlerts({
double? overrideLat,
double? overrideLng,
double? overrideRadius,
bool force = false,
}) async {
Position? pos = _lastObservedPosition;
if (overrideLat != null && overrideLng != null) {
pos = Position(
latitude: overrideLat,
longitude: overrideLng,
timestamp: DateTime.now(),
accuracy: 5.0,
altitude: 0.0,
altitudeAccuracy: 0.0,
heading: 0.0,
headingAccuracy: 0.0,
speed: 0.0,
speedAccuracy: 0.0,
);
} else if (pos == null) {
try {
pos = _ref.read(locationServiceProvider).lastValidPosition;
} catch (_) {}
if (pos == null) {
try {
pos = await Geolocator.getLastKnownPosition();
} catch (_) {}
}
}
if (pos != null) {
await _processPosition(pos, force: force, overrideRadius: overrideRadius);
}
}
Future<void> _processPosition(
Position position, {
bool force = false,
double? overrideRadius,
}) async {
_lastObservedPosition = position;
final isEnabled = _ref.read(roadAlertsEnabledProvider);
if (!isEnabled) {
@ -160,7 +218,10 @@ class RoadAlertService {
}
final now = DateTime.now();
final radiusMeters = _ref.read(alertRadiusMetersProvider);
final double tacticalRadiusMeters = overrideRadius ??
_ref.read(tacticalAlertRadiusMetersProvider) ??
25000.0;
final audioRadiusMeters = _ref.read(alertRadiusMetersProvider);
final audioEnabled = _ref.read(audioAlertsEnabledProvider);
final enabledCategories = _ref.read(enabledCategoriesProvider);
final tomtomApiKey = _ref.read(tomtomApiKeyProvider);
@ -172,7 +233,9 @@ class RoadAlertService {
// Request serialization check: prevent overlapping queries
bool shouldFetch = false;
if (!_isFetching) {
if (force) {
shouldFetch = true;
} else if (!_isFetching) {
if (_lastFetchTime == null || now.difference(_lastFetchTime!).inSeconds >= 45) {
shouldFetch = true;
} else if (_lastFetchPosition != null) {
@ -188,7 +251,7 @@ class RoadAlertService {
}
}
if (shouldFetch) {
if (shouldFetch && !_isFetching) {
_isFetching = true;
_lastFetchTime = now;
_lastFetchPosition = position;
@ -196,7 +259,7 @@ class RoadAlertService {
await _fetchAlertsFromProviders(
position.latitude,
position.longitude,
radiusMeters,
tacticalRadiusMeters,
tomtomApiKey: tomtomApiKey,
communityApiKey: communityApiKey,
hereApiKey: hereApiKey,
@ -207,14 +270,27 @@ class RoadAlertService {
}
}
int staleCount = 0;
int disabledCategoryCount = 0;
int outOfRangeCount = 0;
int dismissedCount = 0;
// Process cached alerts & filter out expired ones
final List<RoadAlert> inRangeAlerts = [];
for (var alert in _cachedAlerts) {
final key = '${alert.providerId}_${alert.id}';
if (_dismissedAlertIds.contains(alert.id) || _dismissedAlertIds.contains(key)) {
dismissedCount++;
continue;
}
if (alert.expiresAt != null && alert.expiresAt!.isBefore(now)) {
staleCount++;
continue;
}
if (!enabledCategories.contains(alert.category)) {
disabledCategoryCount++;
continue;
}
@ -225,8 +301,10 @@ class RoadAlertService {
alert.longitude,
);
if (dist <= radiusMeters * 1.5) {
if (dist <= tacticalRadiusMeters) {
inRangeAlerts.add(alert.copyWith(distanceFromUser: dist));
} else {
outOfRangeCount++;
}
}
@ -235,6 +313,7 @@ class RoadAlertService {
inRangeAlerts,
dismissedIds: _dismissedAlertIds,
);
final deduplicatedDroppedCount = inRangeAlerts.length - deduplicatedAlerts.length;
// Apply jurisdictional regulatory compliance (US standard, France danger zone, Germany/Switzerland prohibited)
final compliantList = List<RoadAlert>.from(
@ -245,6 +324,7 @@ class RoadAlertService {
complianceEnabled: complianceEnabled,
),
);
final complianceDroppedCount = deduplicatedAlerts.length - compliantList.length;
final navSession = _ref.read(navigationSessionProvider);
final hasActiveRoute = navSession.isNavigating &&
@ -277,13 +357,15 @@ class RoadAlertService {
_cachedAlerts = compliantList;
_alertsController.add(compliantList);
// Approach Audio Warning with lean-angle cornering safety interlock and speech cooldown
// Approach Audio Warning: evaluated against proximity threshold (audioRadiusMeters / 400m)
final List<RoadAlert> audioCandidates = [];
for (var updatedAlert in compliantList) {
final key = '${updatedAlert.providerId}_${updatedAlert.id}';
final dist = updatedAlert.distanceFromUser ?? 999999.0;
if (audioEnabled && dist <= 400.0 && !_announcedAlertIds.contains(key)) {
final isAhead = _isAlertAheadOfRider(position, updatedAlert);
if (isAhead) {
final approachThreshold = math.min(audioRadiusMeters, 400.0);
if (dist <= approachThreshold && _isAlertAheadOfRider(position, updatedAlert)) {
audioCandidates.add(updatedAlert);
if (audioEnabled && !_announcedAlertIds.contains(key)) {
final isCornering = _currentLeanAngle > 15.0 ||
(_lastSteepLeanTime != null &&
now.difference(_lastSteepLeanTime!).inMilliseconds < 1500);
@ -314,6 +396,46 @@ class RoadAlertService {
}
}
}
// Publish live pipeline diagnostics telemetry
final zylaProvider = _ref.read(zylaProviderInstance);
final zylaStatus = _ref.read(zylaStatusProvider);
final rejectedReasons = <String, int>{
if (staleCount > 0) 'stale_or_expired': staleCount,
if (disabledCategoryCount > 0) 'category_disabled': disabledCategoryCount,
if (outOfRangeCount > 0) 'out_of_tactical_range': outOfRangeCount,
if (deduplicatedDroppedCount > 0) 'deduplicated': deduplicatedDroppedCount,
if (complianceDroppedCount > 0) 'compliance_suppressed': complianceDroppedCount,
if (dismissedCount > 0) 'dismissed': dismissedCount,
};
final latDir = position.latitude >= 0 ? 'N' : 'S';
final lngDir = position.longitude >= 0 ? 'E' : 'W';
final areaStr = '${position.latitude.abs().toStringAsFixed(2)}° $latDir, '
'${position.longitude.abs().toStringAsFixed(2)}° $lngDir '
'(${(tacticalRadiusMeters / 1000).toStringAsFixed(1)} km radius)';
_ref.read(roadAlertDiagnosticsProvider.notifier).updateDiagnostics(
RoadAlertDiagnostics(
providerStatus: zylaProvider.lastStatus != RoadAlertProviderStatus.notConfigured
? zylaProvider.lastStatus
: zylaStatus,
statusMessage: zylaProvider.lastError ??
(zylaStatus == RoadAlertProviderStatus.connected ? 'Operational' : zylaStatus.name),
lastFetchTime: _lastFetchTime ?? now,
lastSuccessfulFetchTime: _lastSuccessfulFetchTime,
lastRequestedArea: areaStr,
lastRequestedRadiusMeters: tacticalRadiusMeters,
isLive: zylaProvider.lastWasLive,
rawReportsCount: zylaProvider.lastRawCount,
normalizedAcceptedCount: _cachedAlerts.length + staleCount + disabledCategoryCount + outOfRangeCount,
rejectedReasons: rejectedReasons,
displayedOnMapCount: compliantList.length,
audioCandidateCount: audioCandidates.length,
lastError: zylaProvider.lastError,
),
);
}
bool _isAlertAheadOfRider(Position userPos, RoadAlert alert) {
@ -394,6 +516,9 @@ class RoadAlertService {
}
_cachedAlerts = newAlerts;
if (providers.isNotEmpty) {
_lastSuccessfulFetchTime = DateTime.now();
}
if (kDebugMode) {
debugPrint('[RoadAlerts] Active cached alerts count: ${_cachedAlerts.length}');
}

View File

@ -49,6 +49,18 @@ class ZylaRoadAlertProvider implements RoadAlertProvider {
RoadAlertCategory.other,
};
int _lastRawCount = 0;
int get lastRawCount => _lastRawCount;
RoadAlertProviderStatus _lastStatus = RoadAlertProviderStatus.notConfigured;
RoadAlertProviderStatus get lastStatus => _lastStatus;
String? _lastError;
String? get lastError => _lastError;
bool _lastWasLive = true;
bool get lastWasLive => _lastWasLive;
Future<Map<String, String>> _buildHeaders({String? apiKey}) async {
final headers = <String, String>{
'Accept': 'application/json',
@ -80,6 +92,8 @@ class ZylaRoadAlertProvider implements RoadAlertProvider {
if (kDebugMode) {
debugPrint('[ZylaRoadAlertProvider] testConnection: Missing Authorization token (auth token returned null/empty and no API key provided)');
}
_lastStatus = RoadAlertProviderStatus.authError;
_lastError = 'Firebase authentication required: please sign in';
return RoadAlertProviderStatus.authError;
}
final uri = Uri.parse('$backendBaseUrl/api/v1/alerts/ops/health');
@ -90,25 +104,52 @@ class ZylaRoadAlertProvider implements RoadAlertProvider {
for (final p in providers) {
if (p['provider_id'] == id) {
final status = p['status'];
if (status == 'operational') return RoadAlertProviderStatus.connected;
if (status == 'circuit_open') return RoadAlertProviderStatus.rateLimited;
if (status == 'auth_missing') return RoadAlertProviderStatus.authError;
if (status == 'operational') {
_lastStatus = RoadAlertProviderStatus.connected;
_lastError = null;
return RoadAlertProviderStatus.connected;
}
if (status == 'circuit_open') {
_lastStatus = RoadAlertProviderStatus.rateLimited;
_lastError = 'Upstream circuit open / quota budget exhausted';
return RoadAlertProviderStatus.rateLimited;
}
if (status == 'auth_missing') {
_lastStatus = RoadAlertProviderStatus.authError;
_lastError = 'Upstream gateway credentials not configured on backend';
return RoadAlertProviderStatus.authError;
}
_lastStatus = RoadAlertProviderStatus.notConfigured;
_lastError = 'Provider status: $status';
return RoadAlertProviderStatus.notConfigured;
}
}
_lastStatus = RoadAlertProviderStatus.connected;
_lastError = null;
return RoadAlertProviderStatus.connected;
}
if (res.statusCode == 401 || res.statusCode == 403) {
if (kDebugMode) {
debugPrint('[ZylaRoadAlertProvider] testConnection auth rejected: ${res.statusCode} ${res.body}');
}
_lastStatus = RoadAlertProviderStatus.authError;
_lastError = 'Firebase authorization token rejected (HTTP ${res.statusCode})';
return RoadAlertProviderStatus.authError;
}
if (res.statusCode == 429) {
_lastStatus = RoadAlertProviderStatus.rateLimited;
_lastError = 'Rate limit or monthly quota exceeded (HTTP 429)';
return RoadAlertProviderStatus.rateLimited;
}
_lastStatus = RoadAlertProviderStatus.networkError;
_lastError = 'Backend returned HTTP ${res.statusCode}';
return RoadAlertProviderStatus.networkError;
} catch (e) {
if (kDebugMode) {
debugPrint('[ZylaRoadAlertProvider] testConnection error: $e');
}
_lastStatus = RoadAlertProviderStatus.networkError;
_lastError = 'Connection failed: $e';
return RoadAlertProviderStatus.networkError;
}
}
@ -121,6 +162,8 @@ class ZylaRoadAlertProvider implements RoadAlertProvider {
String? apiKey,
String? fixtureOverride,
}) async {
_lastWasLive = fixtureOverride == null || fixtureOverride.isEmpty;
// Convert center + radius to bounding box
const metersPerDegreeLat = 111320.0;
final latDelta = radiusMeters / metersPerDegreeLat;
@ -132,12 +175,13 @@ class ZylaRoadAlertProvider implements RoadAlertProvider {
final minLng = (longitude - lngDelta).clamp(-180.0, 180.0);
final maxLng = (longitude + lngDelta).clamp(-180.0, 180.0);
final queryParams = {
final queryParams = <String, String>{
'min_lat': minLat.toStringAsFixed(4),
'min_lng': minLng.toStringAsFixed(4),
'max_lat': maxLat.toStringAsFixed(4),
'max_lng': maxLng.toStringAsFixed(4),
'fixture_override': ?fixtureOverride,
if (fixtureOverride != null && fixtureOverride.isNotEmpty)
'fixture_override': fixtureOverride,
};
final uri = Uri.parse('$backendBaseUrl/api/v1/alerts/zyla').replace(
@ -146,24 +190,42 @@ class ZylaRoadAlertProvider implements RoadAlertProvider {
try {
final headers = await _buildHeaders(apiKey: apiKey);
if (getAuthToken != null && !headers.containsKey('Authorization')) {
_lastStatus = RoadAlertProviderStatus.authError;
_lastError = 'Firebase authentication required: please sign in';
return [];
}
final response = await _client.get(uri, headers: headers).timeout(const Duration(seconds: 5));
if (response.statusCode == 401 || response.statusCode == 403) {
if (kDebugMode) {
debugPrint('[ZylaRoadAlertProvider] Backend rejected request with auth status ${response.statusCode}');
}
_lastStatus = RoadAlertProviderStatus.authError;
_lastError = 'Firebase token expired or unauthorized (${response.statusCode})';
return [];
}
if (response.statusCode == 429) {
_lastStatus = RoadAlertProviderStatus.rateLimited;
_lastError = 'Monthly API budget (500 reqs) or rate limit exceeded (429)';
return [];
}
if (response.statusCode != 200) {
if (kDebugMode) {
debugPrint('[ZylaRoadAlertProvider] Backend returned status ${response.statusCode}');
}
_lastStatus = RoadAlertProviderStatus.networkError;
_lastError = 'Backend returned HTTP ${response.statusCode}';
return [];
}
final data = json.decode(response.body);
final rawAlerts = data['alerts'] as List<dynamic>? ?? [];
final List<RoadAlert> alerts = [];
_lastRawCount = rawAlerts.length;
_lastStatus = RoadAlertProviderStatus.connected;
_lastError = null;
final List<RoadAlert> alerts = [];
for (final raw in rawAlerts) {
final alert = _parseNormalizedAlert(raw, userLat: latitude, userLng: longitude);
if (alert != null) {
@ -176,6 +238,8 @@ class ZylaRoadAlertProvider implements RoadAlertProvider {
if (kDebugMode) {
debugPrint('[ZylaRoadAlertProvider] Failed to fetch alerts: $e');
}
_lastStatus = RoadAlertProviderStatus.networkError;
_lastError = 'Network connection failed: $e';
return []; // Graceful degradation
}
}

View File

@ -48,6 +48,7 @@ final locationPermissionGrantedProvider = FutureProvider<bool>((ref) async {
class LocationService {
Position? _lastValidPosition;
Position? get lastValidPosition => _lastValidPosition;
double? _emaSpeed;
final StreamController<Position> _controller = StreamController<Position>.broadcast();

View File

@ -0,0 +1,400 @@
import 'dart:convert';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:motorcycle_dash/models/field_test_event.dart';
import 'package:motorcycle_dash/services/field_test/field_test_logger.dart';
import 'package:motorcycle_dash/services/location_service.dart';
import 'package:motorcycle_dash/services/map/map_layer_settings.dart';
import 'package:motorcycle_dash/services/alerts/road_alert_models.dart';
import 'package:motorcycle_dash/services/alerts/road_alert_providers.dart';
import 'package:motorcycle_dash/services/alerts/road_alert_service.dart';
import 'package:motorcycle_dash/services/alerts/zyla_road_alert_provider.dart';
class _NoOpFieldTestLogger extends Fake implements FieldTestLogger {
@override
void log({
required FieldTestEventType eventType,
required EventSeverity severity,
required String message,
int? rideId,
DateTime? timestamp,
DateTime? telemetryTimestamp,
int? gpsAgeMs,
double? speed,
double? latitude,
double? longitude,
double? gpsAccuracy,
double? leanAngle,
double? longitudinalG,
double? lateralG,
double? overallConfidence,
Map<String, dynamic>? metadata,
}) {}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const flutterTtsChannel = MethodChannel('flutter_tts');
const sensorsChannel = MethodChannel('dev.fluttercommunity.plus/sensors/method');
setUpAll(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(flutterTtsChannel, (call) async {
return 1;
});
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(sensorsChannel, (call) async {
return null;
});
});
tearDownAll(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(flutterTtsChannel, null);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(sensorsChannel, null);
});
Map<String, dynamic> createRomeSampleResponse() {
final cur = DateTime.now();
return {
'success': true,
'count': 3,
'alerts': [
{
'id': 'zyla_rome_pol_01',
'category': 'police',
'subtype': 'police_visible',
'latitude': 34.2750,
'longitude': -85.1700,
'road_name': 'Martha Berry Hwy',
'description': 'Stationary radar trap Northbound on shoulder',
'reported_at_utc': cur.subtract(const Duration(minutes: 5)).toIso8601String(),
'expires_at_utc': cur.add(const Duration(minutes: 40)).toIso8601String(),
'confidence': 0.8,
'source': 'Zyla Waze Data #4814',
},
{
'id': 'zyla_rome_haz_01',
'category': 'hazard',
'subtype': 'hazard_on_road',
'latitude': 34.2500,
'longitude': -85.2000,
'road_name': 'GA-20 / Alabama Hwy',
'description': 'Tree branch in right lane around bend',
'reported_at_utc': cur.subtract(const Duration(minutes: 8)).toIso8601String(),
'expires_at_utc': cur.add(const Duration(minutes: 30)).toIso8601String(),
'confidence': 0.9,
'source': 'Zyla Waze Data #4814',
},
{
'id': 'zyla_rome_acc_01',
'category': 'accident',
'subtype': 'accident_minor',
'latitude': 34.2580,
'longitude': -85.1650,
'road_name': 'Turner McCall Blvd',
'description': 'Minor fender bender blocking left turn lane',
'reported_at_utc': cur.subtract(const Duration(minutes: 12)).toIso8601String(),
'expires_at_utc': cur.add(const Duration(minutes: 45)).toIso8601String(),
'confidence': 0.85,
'source': 'Zyla Waze Data #4814',
},
],
};
}
group('Zyla End-to-End Alert Ingestion and Diagnostics Tests', () {
test('1. Parses realistic recorded Zyla responses and populates metadata correctly', () async {
final mockClient = MockClient((request) async {
expect(request.url.path, equals('/api/v1/alerts/zyla'));
expect(request.url.queryParameters['min_lat'], isNotNull);
expect(request.url.queryParameters['max_lat'], isNotNull);
expect(request.url.queryParameters['fixture_override'], isNull); // not appended when null
return http.Response(json.encode(createRomeSampleResponse()), 200);
});
final provider = ZylaRoadAlertProvider(
client: mockClient,
backendBaseUrl: 'http://127.0.0.1:8000',
getAuthToken: () async => 'mock_token',
);
final alerts = await provider.getAlerts(
latitude: 34.2560,
longitude: -85.1650,
radiusMeters: 25000.0,
);
expect(alerts.length, equals(3));
expect(provider.lastRawCount, equals(3));
expect(provider.lastStatus, equals(RoadAlertProviderStatus.connected));
expect(provider.lastWasLive, isTrue);
expect(provider.lastError, isNull);
final policeAlert = alerts.firstWhere((a) => a.id == 'zyla_rome_pol_01');
expect(policeAlert.category, equals(RoadAlertCategory.police));
expect(policeAlert.description, contains('Stationary radar trap'));
expect(policeAlert.latitude, equals(34.2750));
expect(policeAlert.longitude, equals(-85.1700));
expect(policeAlert.roadName, equals('Martha Berry Hwy'));
});
test('2. Decoupled Tactical Horizon: displays alerts within 25 km on map without triggering premature audio warnings', () async {
SharedPreferences.setMockInitialValues({});
final mockClient = MockClient((request) async {
return http.Response(json.encode(createRomeSampleResponse()), 200);
});
final customZylaProvider = ZylaRoadAlertProvider(
client: mockClient,
backendBaseUrl: 'http://127.0.0.1:8000',
getAuthToken: () async => 'mock_token',
);
final container = ProviderContainer(
overrides: [
fieldTestLoggerProvider.overrideWithValue(_NoOpFieldTestLogger()),
zylaProviderInstance.overrideWithValue(customZylaProvider),
zylaRoadAlertsEnabledProvider.overrideWith((ref) {
final notifier = ZylaRoadAlertsEnabledNotifier();
notifier.state = true;
return notifier;
}),
],
);
addTearDown(container.dispose);
final service = container.read(roadAlertServiceProvider);
// Rider located in Downtown Rome (~2.2 km away from police alert, ~3.3 km from hazard alert)
await service.refreshAlerts(
overrideLat: 34.2560,
overrideLng: -85.1650,
overrideRadius: 25000.0,
force: true,
);
expect(service.cachedAlerts.length, equals(3));
final diag = container.read(roadAlertDiagnosticsProvider);
expect(diag.displayedOnMapCount, equals(3));
expect(diag.rawReportsCount, equals(3));
expect(diag.lastRequestedArea, contains('25.0 km radius'));
// Police alert is 2.2 km away -> MUST NOT be in immediate audio candidate list (<400m)
expect(diag.audioCandidateCount, equals(1)); // only accident at ~220m is an audio candidate
});
test('3. Stage-by-Stage Rejection Funnel: tracks stale, disabled category, out of range, and dismissed counts', () async {
SharedPreferences.setMockInitialValues({});
final cur = DateTime.now();
final mixedAlertsResponse = {
'success': true,
'count': 4,
'alerts': [
{
'id': 'alert_valid_police',
'category': 'police',
'latitude': 34.2562,
'longitude': -85.1652,
'description': 'Active radar',
'reported_at_utc': cur.subtract(const Duration(minutes: 5)).toIso8601String(),
'expires_at_utc': cur.add(const Duration(minutes: 30)).toIso8601String(),
'source': 'Zyla',
},
{
'id': 'alert_expired_hazard',
'category': 'hazard',
'latitude': 34.2565,
'longitude': -85.1655,
'description': 'Old pothole',
'reported_at_utc': cur.subtract(const Duration(hours: 3)).toIso8601String(),
'expires_at_utc': cur.subtract(const Duration(minutes: 10)).toIso8601String(), // EXPIRED
'source': 'Zyla',
},
{
'id': 'alert_far_away',
'category': 'hazard',
'latitude': 35.5000, // ~140 km away (out of 25 km range)
'longitude': -85.1650,
'description': 'Mountain pass rockslide',
'reported_at_utc': cur.toIso8601String(),
'expires_at_utc': cur.add(const Duration(hours: 1)).toIso8601String(),
'source': 'Zyla',
},
{
'id': 'alert_weather',
'category': 'weatherHazard',
'latitude': 34.2570,
'longitude': -85.1660,
'description': 'Flash flood',
'reported_at_utc': cur.toIso8601String(),
'expires_at_utc': cur.add(const Duration(hours: 1)).toIso8601String(),
'source': 'Zyla',
},
],
};
final mockClient = MockClient((request) async {
return http.Response(json.encode(mixedAlertsResponse), 200);
});
final customZylaProvider = ZylaRoadAlertProvider(
client: mockClient,
backendBaseUrl: 'http://127.0.0.1:8000',
getAuthToken: () async => 'mock_token',
);
final container = ProviderContainer(
overrides: [
fieldTestLoggerProvider.overrideWithValue(_NoOpFieldTestLogger()),
zylaProviderInstance.overrideWithValue(customZylaProvider),
zylaRoadAlertsEnabledProvider.overrideWith((ref) {
final notifier = ZylaRoadAlertsEnabledNotifier();
notifier.state = true;
return notifier;
}),
],
);
addTearDown(container.dispose);
// Disable weatherHazard category to test category rejection
container.read(enabledCategoriesProvider.notifier).toggleCategory(RoadAlertCategory.weatherHazard, false);
final service = container.read(roadAlertServiceProvider);
// Dismiss alert_valid_police manually
service.dismissAlert('alert_valid_police');
await service.refreshAlerts(
overrideLat: 34.2560,
overrideLng: -85.1650,
overrideRadius: 25000.0,
force: true,
);
final diag = container.read(roadAlertDiagnosticsProvider);
expect(diag.rawReportsCount, equals(4));
expect(diag.rejectedReasons['stale_or_expired'], equals(1));
expect(diag.rejectedReasons['out_of_tactical_range'], equals(1));
expect(diag.rejectedReasons['category_disabled'], equals(1));
expect(diag.rejectedReasons['dismissed'], equals(1));
expect(diag.totalRejectedCount, equals(4));
expect(diag.displayedOnMapCount, equals(0));
});
test('4. MapScreen Police Traps Decoupling: hiding police traps preserves other hazards and closures', () {
final now = DateTime.now();
final policeAlert = RoadAlert(
id: 'p1',
providerId: 'zyla',
category: RoadAlertCategory.police,
latitude: 34.2560,
longitude: -85.1650,
description: 'Speed trap',
reportedAt: now,
lastUpdated: now,
source: 'Waze',
);
final hazardAlert = RoadAlert(
id: 'h1',
providerId: 'zyla',
category: RoadAlertCategory.hazard,
latitude: 34.2570,
longitude: -85.1660,
description: 'Gravel on corner',
reportedAt: now,
lastUpdated: now,
source: 'Waze',
);
final closureAlert = RoadAlert(
id: 'c1',
providerId: 'zyla',
category: RoadAlertCategory.roadClosure,
latitude: 34.2580,
longitude: -85.1670,
description: 'Bridge closed',
reportedAt: now,
lastUpdated: now,
source: 'Waze',
);
final allAlerts = [policeAlert, hazardAlert, closureAlert];
// Case A: showPoliceTraps is TRUE (default)
const settingsWithPolice = MapLayerSettings(showPoliceTraps: true);
final alertsA = allAlerts.where((alert) {
if (!settingsWithPolice.showPoliceTraps &&
(alert.category == RoadAlertCategory.police ||
alert.category == RoadAlertCategory.speedCamera)) {
return false;
}
return true;
}).toList();
expect(alertsA.length, equals(3));
// Case B: showPoliceTraps is FALSE (user toggled off police traps)
const settingsNoPolice = MapLayerSettings(showPoliceTraps: false);
final alertsB = allAlerts.where((alert) {
if (!settingsNoPolice.showPoliceTraps &&
(alert.category == RoadAlertCategory.police ||
alert.category == RoadAlertCategory.speedCamera)) {
return false;
}
return true;
}).toList();
expect(alertsB.length, equals(2));
expect(alertsB.any((a) => a.category == RoadAlertCategory.police), isFalse);
expect(alertsB.any((a) => a.category == RoadAlertCategory.hazard), isTrue);
expect(alertsB.any((a) => a.category == RoadAlertCategory.roadClosure), isTrue);
});
test('5. Quota & Rate Limit Handling: 429 sets rateLimited status with clear explanation and zero secret leakage', () async {
final mockClient = MockClient((request) async {
return http.Response(
json.encode({'detail': 'Monthly quota limit (500 requests) exceeded.'}),
429,
headers: {'content-type': 'application/json'},
);
});
final provider = ZylaRoadAlertProvider(
client: mockClient,
backendBaseUrl: 'http://127.0.0.1:8000',
getAuthToken: () async => 'mock_token',
);
final alerts = await provider.getAlerts(
latitude: 34.2560,
longitude: -85.1650,
radiusMeters: 25000.0,
);
expect(alerts, isEmpty);
expect(provider.lastStatus, equals(RoadAlertProviderStatus.rateLimited));
expect(provider.lastError, contains('Monthly API budget (500 reqs)'));
expect(provider.lastError, isNot(contains('key')));
expect(provider.lastError, isNot(contains('secret')));
});
test('6. Location Bootstrap: LocationService exposes lastValidPosition cleanly', () {
final service = LocationService();
addTearDown(service.dispose);
expect(service.lastValidPosition, isNull);
});
});
}