diff --git a/lib/features/map/map_screen.dart b/lib/features/map/map_screen.dart index b59e1c2..154bdc4 100644 --- a/lib/features/map/map_screen.dart +++ b/lib/features/map/map_screen.dart @@ -77,6 +77,11 @@ class _MapScreenState extends ConsumerState { _isLoadingHistory = false; _initLiveLocation(); } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + ref.read(roadAlertServiceProvider).refreshAlerts(force: true); + } + }); } @override @@ -1087,9 +1092,15 @@ class _MapScreenState extends ConsumerState { ), ); final List mapMarkers = [motorcycleMarker]; - final liveAlerts = layerSettings.showPoliceTraps - ? (liveAlertsAsync.value ?? []) - : []; + 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( diff --git a/lib/features/map/widgets/road_alert_diagnostics_sheet.dart b/lib/features/map/widgets/road_alert_diagnostics_sheet.dart new file mode 100644 index 0000000..bdcfc21 --- /dev/null +++ b/lib/features/map/widgets/road_alert_diagnostics_sheet.dart @@ -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 show(BuildContext context) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => const RoadAlertDiagnosticsSheet(), + ); + } + + @override + ConsumerState createState() => + _RoadAlertDiagnosticsSheetState(); +} + +class _RoadAlertDiagnosticsSheetState + extends ConsumerState { + bool _isRefreshing = false; + bool _isTestingConnection = false; + + Future _handleForcePoll() async { + setState(() => _isRefreshing = true); + try { + await ref.read(roadAlertServiceProvider).refreshAlerts(force: true); + } catch (_) {} + if (mounted) { + setState(() => _isRefreshing = false); + } + } + + Future _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, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/map/widgets/tactical_layers_bottom_sheet.dart b/lib/features/map/widgets/tactical_layers_bottom_sheet.dart index 14bd295..c64f081 100644 --- a/lib/features/map/widgets/tactical_layers_bottom_sheet.dart +++ b/lib/features/map/widgets/tactical_layers_bottom_sheet.dart @@ -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 diff --git a/lib/features/settings/settings_screen.dart b/lib/features/settings/settings_screen.dart index c10ef45..a99334d 100644 --- a/lib/features/settings/settings_screen.dart +++ b/lib/features/settings/settings_screen.dart @@ -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 { ), ), 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)), + ), + ], ), ], ), diff --git a/lib/services/alerts/road_alert_diagnostics.dart b/lib/services/alerts/road_alert_diagnostics.dart new file mode 100644 index 0000000..06cd7f1 --- /dev/null +++ b/lib/services/alerts/road_alert_diagnostics.dart @@ -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 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? 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, + ); + } +} diff --git a/lib/services/alerts/road_alert_models.dart b/lib/services/alerts/road_alert_models.dart index 2ec35c3..172de96 100644 --- a/lib/services/alerts/road_alert_models.dart +++ b/lib/services/alerts/road_alert_models.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +export 'road_alert_diagnostics.dart'; enum RoadAlertCategory { police, diff --git a/lib/services/alerts/road_alert_providers.dart b/lib/services/alerts/road_alert_providers.dart index b952cac..01a9ffc 100644 --- a/lib/services/alerts/road_alert_providers.dart +++ b/lib/services/alerts/road_alert_providers.dart @@ -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 { + static const double defaultRadius = 25000.0; + TacticalAlertRadiusMetersNotifier() : super(defaultRadius); + + void setRadius(double radiusMeters) { + state = radiusMeters; + } +} + +final tacticalAlertRadiusMetersProvider = + StateNotifierProvider((ref) { + return TacticalAlertRadiusMetersNotifier(); +}); + +/// Live pipeline diagnostics state notifier +class RoadAlertDiagnosticsNotifier extends StateNotifier { + 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((ref) { + return RoadAlertDiagnosticsNotifier(); +}); + diff --git a/lib/services/alerts/road_alert_service.dart b/lib/services/alerts/road_alert_service.dart index 7197b4f..9875943 100644 --- a/lib/services/alerts/road_alert_service.dart +++ b/lib/services/alerts/road_alert_service.dart @@ -40,6 +40,7 @@ class RoadAlertService { StreamSubscription? _leanSub; DateTime? _lastFetchTime; + DateTime? _lastSuccessfulFetchTime; Position? _lastFetchPosition; Position? _lastObservedPosition; bool _isFetching = false; // Request serialization lock @@ -55,6 +56,7 @@ class RoadAlertService { Timer? _cleanupTimer; Stream> get alertsStream => _alertsController.stream; + List 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 _processPosition(Position position) async { + /// Public refresh method: allows MapScreen, routes, or user diagnostics + /// to force a live or localized alert refresh. + Future 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 _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 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.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 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 = { + 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}'); } diff --git a/lib/services/alerts/zyla_road_alert_provider.dart b/lib/services/alerts/zyla_road_alert_provider.dart index a2f1893..a1d6a18 100644 --- a/lib/services/alerts/zyla_road_alert_provider.dart +++ b/lib/services/alerts/zyla_road_alert_provider.dart @@ -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> _buildHeaders({String? apiKey}) async { final headers = { '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 = { '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? ?? []; - final List alerts = []; + _lastRawCount = rawAlerts.length; + _lastStatus = RoadAlertProviderStatus.connected; + _lastError = null; + final List 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 } } diff --git a/lib/services/location_service.dart b/lib/services/location_service.dart index 6d76252..408325a 100644 --- a/lib/services/location_service.dart +++ b/lib/services/location_service.dart @@ -48,6 +48,7 @@ final locationPermissionGrantedProvider = FutureProvider((ref) async { class LocationService { Position? _lastValidPosition; + Position? get lastValidPosition => _lastValidPosition; double? _emaSpeed; final StreamController _controller = StreamController.broadcast(); diff --git a/test/road_alert_diagnostics_and_pipeline_test.dart b/test/road_alert_diagnostics_and_pipeline_test.dart new file mode 100644 index 0000000..7ec8511 --- /dev/null +++ b/test/road_alert_diagnostics_and_pipeline_test.dart @@ -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? 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 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); + }); + }); +}