fix(alerts): decouple tactical map horizon, add diagnostics HUD, and trace Zyla pipeline
This commit is contained in:
parent
5fcaafbe36
commit
ad99da10ba
|
|
@ -77,6 +77,11 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
||||||
_isLoadingHistory = false;
|
_isLoadingHistory = false;
|
||||||
_initLiveLocation();
|
_initLiveLocation();
|
||||||
}
|
}
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) {
|
||||||
|
ref.read(roadAlertServiceProvider).refreshAlerts(force: true);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -1087,9 +1092,15 @@ class _MapScreenState extends ConsumerState<MapScreen> {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
final List<AppMapMarker> mapMarkers = [motorcycleMarker];
|
final List<AppMapMarker> mapMarkers = [motorcycleMarker];
|
||||||
final liveAlerts = layerSettings.showPoliceTraps
|
final allLiveAlerts = liveAlertsAsync.value ?? [];
|
||||||
? (liveAlertsAsync.value ?? [])
|
final liveAlerts = allLiveAlerts.where((alert) {
|
||||||
: <RoadAlert>[];
|
if (!layerSettings.showPoliceTraps &&
|
||||||
|
(alert.category == RoadAlertCategory.police ||
|
||||||
|
alert.category == RoadAlertCategory.speedCamera)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}).toList();
|
||||||
for (var alert in liveAlerts) {
|
for (var alert in liveAlerts) {
|
||||||
mapMarkers.add(
|
mapMarkers.add(
|
||||||
AppMapMarker(
|
AppMapMarker(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../../core/theme/theme_extensions.dart';
|
import '../../../core/theme/theme_extensions.dart';
|
||||||
import '../../../services/map/map_layer_settings.dart';
|
import '../../../services/map/map_layer_settings.dart';
|
||||||
|
import 'road_alert_diagnostics_sheet.dart';
|
||||||
|
|
||||||
/// Stitch MAP-02: Tactical Layer Overrides Bottom Sheet Drawer.
|
/// Stitch MAP-02: Tactical Layer Overrides Bottom Sheet Drawer.
|
||||||
///
|
///
|
||||||
|
|
@ -298,6 +299,56 @@ class TacticalLayersBottomSheet extends ConsumerWidget {
|
||||||
onChanged: notifier.toggleOfflineOnly,
|
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),
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
// Section 3: Action Buttons
|
// Section 3: Action Buttons
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import '../../services/alerts/widgets/quick_swarm_report_modal.dart';
|
||||||
import '../../services/auth/auth_providers.dart';
|
import '../../services/auth/auth_providers.dart';
|
||||||
import '../../services/field_test/field_test_logger.dart';
|
import '../../services/field_test/field_test_logger.dart';
|
||||||
import '../../services/sensor_fusion_service.dart';
|
import '../../services/sensor_fusion_service.dart';
|
||||||
|
import '../map/widgets/road_alert_diagnostics_sheet.dart';
|
||||||
import 'widgets/current_plan_card.dart';
|
import 'widgets/current_plan_card.dart';
|
||||||
|
|
||||||
class SettingsScreen extends ConsumerStatefulWidget {
|
class SettingsScreen extends ConsumerStatefulWidget {
|
||||||
|
|
@ -1100,46 +1101,64 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Align(
|
Wrap(
|
||||||
alignment: Alignment.centerRight,
|
alignment: WrapAlignment.end,
|
||||||
child: ElevatedButton.icon(
|
spacing: 8,
|
||||||
style: ElevatedButton.styleFrom(
|
runSpacing: 8,
|
||||||
minimumSize: const Size(140, 48),
|
children: [
|
||||||
shape: RoundedRectangleBorder(
|
OutlinedButton.icon(
|
||||||
borderRadius: BorderRadius.circular(8),
|
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
|
ElevatedButton.icon(
|
||||||
? null
|
style: ElevatedButton.styleFrom(
|
||||||
: () async {
|
minimumSize: const Size(130, 44),
|
||||||
setState(() => _isTestingZyla = true);
|
shape: RoundedRectangleBorder(
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
borderRadius: BorderRadius.circular(8),
|
||||||
final provider = ref.read(zylaProviderInstance);
|
),
|
||||||
final status = await provider.testConnection('');
|
),
|
||||||
ref.read(zylaStatusProvider.notifier).state = status;
|
onPressed: _isTestingZyla
|
||||||
setState(() => _isTestingZyla = false);
|
? 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) {
|
if (mounted) {
|
||||||
messenger.showSnackBar(
|
messenger.showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text(
|
||||||
status == RoadAlertProviderStatus.connected
|
status == RoadAlertProviderStatus.connected
|
||||||
? 'Zyla adapter operational!'
|
? 'Zyla adapter operational!'
|
||||||
: 'Zyla gateway status: ${status.name}',
|
: 'Zyla gateway status: ${status.name}',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
}
|
||||||
}
|
},
|
||||||
},
|
icon: _isTestingZyla
|
||||||
icon: _isTestingZyla
|
? const SizedBox(
|
||||||
? const SizedBox(
|
width: 16,
|
||||||
width: 16,
|
height: 16,
|
||||||
height: 16,
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
)
|
||||||
)
|
: const Icon(Icons.sync, size: 18),
|
||||||
: const Icon(Icons.sync, size: 18),
|
label: const Text('Test Connection', style: TextStyle(fontSize: 12)),
|
||||||
label: const Text('Test Connection'),
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
export 'road_alert_diagnostics.dart';
|
||||||
|
|
||||||
enum RoadAlertCategory {
|
enum RoadAlertCategory {
|
||||||
police,
|
police,
|
||||||
|
|
|
||||||
|
|
@ -657,3 +657,45 @@ final jurisdictionComplianceEnabledProvider =
|
||||||
return JurisdictionComplianceEnabledNotifier();
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ class RoadAlertService {
|
||||||
StreamSubscription<double>? _leanSub;
|
StreamSubscription<double>? _leanSub;
|
||||||
|
|
||||||
DateTime? _lastFetchTime;
|
DateTime? _lastFetchTime;
|
||||||
|
DateTime? _lastSuccessfulFetchTime;
|
||||||
Position? _lastFetchPosition;
|
Position? _lastFetchPosition;
|
||||||
Position? _lastObservedPosition;
|
Position? _lastObservedPosition;
|
||||||
bool _isFetching = false; // Request serialization lock
|
bool _isFetching = false; // Request serialization lock
|
||||||
|
|
@ -55,6 +56,7 @@ class RoadAlertService {
|
||||||
Timer? _cleanupTimer;
|
Timer? _cleanupTimer;
|
||||||
|
|
||||||
Stream<List<RoadAlert>> get alertsStream => _alertsController.stream;
|
Stream<List<RoadAlert>> get alertsStream => _alertsController.stream;
|
||||||
|
List<RoadAlert> get cachedAlerts => List.unmodifiable(_cachedAlerts);
|
||||||
double get currentLeanAngle => _currentLeanAngle;
|
double get currentLeanAngle => _currentLeanAngle;
|
||||||
bool get isCornering =>
|
bool get isCornering =>
|
||||||
_currentLeanAngle > 15.0 ||
|
_currentLeanAngle > 15.0 ||
|
||||||
|
|
@ -91,6 +93,20 @@ class RoadAlertService {
|
||||||
_processPosition(position);
|
_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 ?? (() {
|
final stream = leanStream ?? (() {
|
||||||
try {
|
try {
|
||||||
return _ref.read(sensorFusionServiceProvider).leanAngleStream;
|
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;
|
_lastObservedPosition = position;
|
||||||
final isEnabled = _ref.read(roadAlertsEnabledProvider);
|
final isEnabled = _ref.read(roadAlertsEnabledProvider);
|
||||||
if (!isEnabled) {
|
if (!isEnabled) {
|
||||||
|
|
@ -160,7 +218,10 @@ class RoadAlertService {
|
||||||
}
|
}
|
||||||
|
|
||||||
final now = DateTime.now();
|
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 audioEnabled = _ref.read(audioAlertsEnabledProvider);
|
||||||
final enabledCategories = _ref.read(enabledCategoriesProvider);
|
final enabledCategories = _ref.read(enabledCategoriesProvider);
|
||||||
final tomtomApiKey = _ref.read(tomtomApiKeyProvider);
|
final tomtomApiKey = _ref.read(tomtomApiKeyProvider);
|
||||||
|
|
@ -172,7 +233,9 @@ class RoadAlertService {
|
||||||
|
|
||||||
// Request serialization check: prevent overlapping queries
|
// Request serialization check: prevent overlapping queries
|
||||||
bool shouldFetch = false;
|
bool shouldFetch = false;
|
||||||
if (!_isFetching) {
|
if (force) {
|
||||||
|
shouldFetch = true;
|
||||||
|
} else if (!_isFetching) {
|
||||||
if (_lastFetchTime == null || now.difference(_lastFetchTime!).inSeconds >= 45) {
|
if (_lastFetchTime == null || now.difference(_lastFetchTime!).inSeconds >= 45) {
|
||||||
shouldFetch = true;
|
shouldFetch = true;
|
||||||
} else if (_lastFetchPosition != null) {
|
} else if (_lastFetchPosition != null) {
|
||||||
|
|
@ -188,7 +251,7 @@ class RoadAlertService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldFetch) {
|
if (shouldFetch && !_isFetching) {
|
||||||
_isFetching = true;
|
_isFetching = true;
|
||||||
_lastFetchTime = now;
|
_lastFetchTime = now;
|
||||||
_lastFetchPosition = position;
|
_lastFetchPosition = position;
|
||||||
|
|
@ -196,7 +259,7 @@ class RoadAlertService {
|
||||||
await _fetchAlertsFromProviders(
|
await _fetchAlertsFromProviders(
|
||||||
position.latitude,
|
position.latitude,
|
||||||
position.longitude,
|
position.longitude,
|
||||||
radiusMeters,
|
tacticalRadiusMeters,
|
||||||
tomtomApiKey: tomtomApiKey,
|
tomtomApiKey: tomtomApiKey,
|
||||||
communityApiKey: communityApiKey,
|
communityApiKey: communityApiKey,
|
||||||
hereApiKey: hereApiKey,
|
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
|
// Process cached alerts & filter out expired ones
|
||||||
final List<RoadAlert> inRangeAlerts = [];
|
final List<RoadAlert> inRangeAlerts = [];
|
||||||
for (var alert in _cachedAlerts) {
|
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)) {
|
if (alert.expiresAt != null && alert.expiresAt!.isBefore(now)) {
|
||||||
|
staleCount++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!enabledCategories.contains(alert.category)) {
|
if (!enabledCategories.contains(alert.category)) {
|
||||||
|
disabledCategoryCount++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -225,8 +301,10 @@ class RoadAlertService {
|
||||||
alert.longitude,
|
alert.longitude,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (dist <= radiusMeters * 1.5) {
|
if (dist <= tacticalRadiusMeters) {
|
||||||
inRangeAlerts.add(alert.copyWith(distanceFromUser: dist));
|
inRangeAlerts.add(alert.copyWith(distanceFromUser: dist));
|
||||||
|
} else {
|
||||||
|
outOfRangeCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -235,6 +313,7 @@ class RoadAlertService {
|
||||||
inRangeAlerts,
|
inRangeAlerts,
|
||||||
dismissedIds: _dismissedAlertIds,
|
dismissedIds: _dismissedAlertIds,
|
||||||
);
|
);
|
||||||
|
final deduplicatedDroppedCount = inRangeAlerts.length - deduplicatedAlerts.length;
|
||||||
|
|
||||||
// Apply jurisdictional regulatory compliance (US standard, France danger zone, Germany/Switzerland prohibited)
|
// Apply jurisdictional regulatory compliance (US standard, France danger zone, Germany/Switzerland prohibited)
|
||||||
final compliantList = List<RoadAlert>.from(
|
final compliantList = List<RoadAlert>.from(
|
||||||
|
|
@ -245,6 +324,7 @@ class RoadAlertService {
|
||||||
complianceEnabled: complianceEnabled,
|
complianceEnabled: complianceEnabled,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
final complianceDroppedCount = deduplicatedAlerts.length - compliantList.length;
|
||||||
|
|
||||||
final navSession = _ref.read(navigationSessionProvider);
|
final navSession = _ref.read(navigationSessionProvider);
|
||||||
final hasActiveRoute = navSession.isNavigating &&
|
final hasActiveRoute = navSession.isNavigating &&
|
||||||
|
|
@ -277,13 +357,15 @@ class RoadAlertService {
|
||||||
_cachedAlerts = compliantList;
|
_cachedAlerts = compliantList;
|
||||||
_alertsController.add(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) {
|
for (var updatedAlert in compliantList) {
|
||||||
final key = '${updatedAlert.providerId}_${updatedAlert.id}';
|
final key = '${updatedAlert.providerId}_${updatedAlert.id}';
|
||||||
final dist = updatedAlert.distanceFromUser ?? 999999.0;
|
final dist = updatedAlert.distanceFromUser ?? 999999.0;
|
||||||
if (audioEnabled && dist <= 400.0 && !_announcedAlertIds.contains(key)) {
|
final approachThreshold = math.min(audioRadiusMeters, 400.0);
|
||||||
final isAhead = _isAlertAheadOfRider(position, updatedAlert);
|
if (dist <= approachThreshold && _isAlertAheadOfRider(position, updatedAlert)) {
|
||||||
if (isAhead) {
|
audioCandidates.add(updatedAlert);
|
||||||
|
if (audioEnabled && !_announcedAlertIds.contains(key)) {
|
||||||
final isCornering = _currentLeanAngle > 15.0 ||
|
final isCornering = _currentLeanAngle > 15.0 ||
|
||||||
(_lastSteepLeanTime != null &&
|
(_lastSteepLeanTime != null &&
|
||||||
now.difference(_lastSteepLeanTime!).inMilliseconds < 1500);
|
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) {
|
bool _isAlertAheadOfRider(Position userPos, RoadAlert alert) {
|
||||||
|
|
@ -394,6 +516,9 @@ class RoadAlertService {
|
||||||
}
|
}
|
||||||
|
|
||||||
_cachedAlerts = newAlerts;
|
_cachedAlerts = newAlerts;
|
||||||
|
if (providers.isNotEmpty) {
|
||||||
|
_lastSuccessfulFetchTime = DateTime.now();
|
||||||
|
}
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
debugPrint('[RoadAlerts] Active cached alerts count: ${_cachedAlerts.length}');
|
debugPrint('[RoadAlerts] Active cached alerts count: ${_cachedAlerts.length}');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,18 @@ class ZylaRoadAlertProvider implements RoadAlertProvider {
|
||||||
RoadAlertCategory.other,
|
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 {
|
Future<Map<String, String>> _buildHeaders({String? apiKey}) async {
|
||||||
final headers = <String, String>{
|
final headers = <String, String>{
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
|
|
@ -80,6 +92,8 @@ class ZylaRoadAlertProvider implements RoadAlertProvider {
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
debugPrint('[ZylaRoadAlertProvider] testConnection: Missing Authorization token (auth token returned null/empty and no API key provided)');
|
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;
|
return RoadAlertProviderStatus.authError;
|
||||||
}
|
}
|
||||||
final uri = Uri.parse('$backendBaseUrl/api/v1/alerts/ops/health');
|
final uri = Uri.parse('$backendBaseUrl/api/v1/alerts/ops/health');
|
||||||
|
|
@ -90,25 +104,52 @@ class ZylaRoadAlertProvider implements RoadAlertProvider {
|
||||||
for (final p in providers) {
|
for (final p in providers) {
|
||||||
if (p['provider_id'] == id) {
|
if (p['provider_id'] == id) {
|
||||||
final status = p['status'];
|
final status = p['status'];
|
||||||
if (status == 'operational') return RoadAlertProviderStatus.connected;
|
if (status == 'operational') {
|
||||||
if (status == 'circuit_open') return RoadAlertProviderStatus.rateLimited;
|
_lastStatus = RoadAlertProviderStatus.connected;
|
||||||
if (status == 'auth_missing') return RoadAlertProviderStatus.authError;
|
_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;
|
return RoadAlertProviderStatus.notConfigured;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
_lastStatus = RoadAlertProviderStatus.connected;
|
||||||
|
_lastError = null;
|
||||||
return RoadAlertProviderStatus.connected;
|
return RoadAlertProviderStatus.connected;
|
||||||
}
|
}
|
||||||
if (res.statusCode == 401 || res.statusCode == 403) {
|
if (res.statusCode == 401 || res.statusCode == 403) {
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
debugPrint('[ZylaRoadAlertProvider] testConnection auth rejected: ${res.statusCode} ${res.body}');
|
debugPrint('[ZylaRoadAlertProvider] testConnection auth rejected: ${res.statusCode} ${res.body}');
|
||||||
}
|
}
|
||||||
|
_lastStatus = RoadAlertProviderStatus.authError;
|
||||||
|
_lastError = 'Firebase authorization token rejected (HTTP ${res.statusCode})';
|
||||||
return RoadAlertProviderStatus.authError;
|
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;
|
return RoadAlertProviderStatus.networkError;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
debugPrint('[ZylaRoadAlertProvider] testConnection error: $e');
|
debugPrint('[ZylaRoadAlertProvider] testConnection error: $e');
|
||||||
}
|
}
|
||||||
|
_lastStatus = RoadAlertProviderStatus.networkError;
|
||||||
|
_lastError = 'Connection failed: $e';
|
||||||
return RoadAlertProviderStatus.networkError;
|
return RoadAlertProviderStatus.networkError;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -121,6 +162,8 @@ class ZylaRoadAlertProvider implements RoadAlertProvider {
|
||||||
String? apiKey,
|
String? apiKey,
|
||||||
String? fixtureOverride,
|
String? fixtureOverride,
|
||||||
}) async {
|
}) async {
|
||||||
|
_lastWasLive = fixtureOverride == null || fixtureOverride.isEmpty;
|
||||||
|
|
||||||
// Convert center + radius to bounding box
|
// Convert center + radius to bounding box
|
||||||
const metersPerDegreeLat = 111320.0;
|
const metersPerDegreeLat = 111320.0;
|
||||||
final latDelta = radiusMeters / metersPerDegreeLat;
|
final latDelta = radiusMeters / metersPerDegreeLat;
|
||||||
|
|
@ -132,12 +175,13 @@ class ZylaRoadAlertProvider implements RoadAlertProvider {
|
||||||
final minLng = (longitude - lngDelta).clamp(-180.0, 180.0);
|
final minLng = (longitude - lngDelta).clamp(-180.0, 180.0);
|
||||||
final maxLng = (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_lat': minLat.toStringAsFixed(4),
|
||||||
'min_lng': minLng.toStringAsFixed(4),
|
'min_lng': minLng.toStringAsFixed(4),
|
||||||
'max_lat': maxLat.toStringAsFixed(4),
|
'max_lat': maxLat.toStringAsFixed(4),
|
||||||
'max_lng': maxLng.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(
|
final uri = Uri.parse('$backendBaseUrl/api/v1/alerts/zyla').replace(
|
||||||
|
|
@ -146,24 +190,42 @@ class ZylaRoadAlertProvider implements RoadAlertProvider {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final headers = await _buildHeaders(apiKey: apiKey);
|
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));
|
final response = await _client.get(uri, headers: headers).timeout(const Duration(seconds: 5));
|
||||||
if (response.statusCode == 401 || response.statusCode == 403) {
|
if (response.statusCode == 401 || response.statusCode == 403) {
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
debugPrint('[ZylaRoadAlertProvider] Backend rejected request with auth status ${response.statusCode}');
|
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 [];
|
return [];
|
||||||
}
|
}
|
||||||
if (response.statusCode != 200) {
|
if (response.statusCode != 200) {
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
debugPrint('[ZylaRoadAlertProvider] Backend returned status ${response.statusCode}');
|
debugPrint('[ZylaRoadAlertProvider] Backend returned status ${response.statusCode}');
|
||||||
}
|
}
|
||||||
|
_lastStatus = RoadAlertProviderStatus.networkError;
|
||||||
|
_lastError = 'Backend returned HTTP ${response.statusCode}';
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
final data = json.decode(response.body);
|
final data = json.decode(response.body);
|
||||||
final rawAlerts = data['alerts'] as List<dynamic>? ?? [];
|
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) {
|
for (final raw in rawAlerts) {
|
||||||
final alert = _parseNormalizedAlert(raw, userLat: latitude, userLng: longitude);
|
final alert = _parseNormalizedAlert(raw, userLat: latitude, userLng: longitude);
|
||||||
if (alert != null) {
|
if (alert != null) {
|
||||||
|
|
@ -176,6 +238,8 @@ class ZylaRoadAlertProvider implements RoadAlertProvider {
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
debugPrint('[ZylaRoadAlertProvider] Failed to fetch alerts: $e');
|
debugPrint('[ZylaRoadAlertProvider] Failed to fetch alerts: $e');
|
||||||
}
|
}
|
||||||
|
_lastStatus = RoadAlertProviderStatus.networkError;
|
||||||
|
_lastError = 'Network connection failed: $e';
|
||||||
return []; // Graceful degradation
|
return []; // Graceful degradation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ final locationPermissionGrantedProvider = FutureProvider<bool>((ref) async {
|
||||||
|
|
||||||
class LocationService {
|
class LocationService {
|
||||||
Position? _lastValidPosition;
|
Position? _lastValidPosition;
|
||||||
|
Position? get lastValidPosition => _lastValidPosition;
|
||||||
double? _emaSpeed;
|
double? _emaSpeed;
|
||||||
|
|
||||||
final StreamController<Position> _controller = StreamController<Position>.broadcast();
|
final StreamController<Position> _controller = StreamController<Position>.broadcast();
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue