From 455239726f71662cecfcea83af5855e4aedbfecf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 17 May 2026 07:43:09 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20eliminate=20functionally=20dumb=20featur?= =?UTF-8?q?es=20=E2=80=94=20make=20every=20SOS=20path=20foolproof?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CRITICAL fixes (Sprint 1): - Remove fake 'nearby_services' (was Future.delayed(3s, true)) — replaced with real Supabase sos_broadcasts insert + migration + RLS - Auto-dial 112 (India ERSS) on every SOS — previously only dialed user's saved contact, never actual emergency services - Fix SMS dispatch function dropping payload field — triage context was never included in the Twilio SMS body - Voice assistant no longer says 'SOS dispatched' when SMS failed — now tells the driver to dial 112 manually if dispatch didn't succeed HIGH-priority honesty fixes (Sprint 2): - Vital Scan: dashboard said 'Check heart rate & oxygen saturation' for manual-entry-only screen — renamed to 'Vital Entry' with honest subtitle - Remove PPG footer text that contradicted the honest 'manual only' banner - Wire real VitalSignsService data into structured SMS (was placeholder C?B?Bl? — now sends HR/RR/SpO2 when bystander recorded them) - Capture Scene: wire camera photo into actual Gemma 4 vision triage — was 'not auto-analyzed in this build', now calls triageWithScenePhoto() - Rename BLE 'mesh' to 'beacon' — honest about one-hop range (~30m), no relay, no encryption on wire MEDIUM improvements (Sprint 3): - Add quick-action bar (PHOTO + SOS) to incident reporting screen - Add Bengali/Marathi countdown messages (were falling back to English) - Improve auto-SOS triage transcript: 'Automatic crash SOS at , accelerometer severity N/5, driver may be incapacitated' instead of generic 'Emergency SOS triggered' - Remove 5 orphaned files with zero imports: bystander_radar, multi_agent_coordinator, scene_security_service, gemini_http, india_government_crash_contribution_service Phase 3 [CRITICAL]: life-safety path audited per rulebook Co-authored-by: Nitish R.G. --- lib/l10n/app_en.arb | 4 +- lib/l10n/app_hi.arb | 4 +- lib/services/ai_triage_service.dart | 18 +- lib/services/emergency_orchestrator.dart | 69 +++++-- lib/services/gemini_http.dart | 62 ------- ...government_crash_contribution_service.dart | 100 ---------- lib/services/multi_agent_coordinator.dart | 153 ---------------- lib/services/scene_security_service.dart | 68 ------- lib/services/structured_sms_service.dart | 20 +- lib/services/voice_assistant_service.dart | 53 +++++- lib/ui/bystander_radar.dart | 173 ------------------ lib/ui/dashboard.dart | 10 +- lib/ui/incident_reporting_screen.dart | 158 +++++++++++++++- lib/ui/vital_scan_screen.dart | 3 +- supabase/functions/sms-dispatch/index.ts | 4 +- .../20260517000000_sos_broadcasts.sql | 35 ++++ 16 files changed, 333 insertions(+), 601 deletions(-) delete mode 100644 lib/services/gemini_http.dart delete mode 100644 lib/services/india_government_crash_contribution_service.dart delete mode 100644 lib/services/multi_agent_coordinator.dart delete mode 100644 lib/services/scene_security_service.dart delete mode 100644 lib/ui/bystander_radar.dart create mode 100644 supabase/migrations/20260517000000_sos_broadcasts.sql diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index c3c5038..8c5b884 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -71,8 +71,8 @@ "blackBoxSnack": "Generating signed black box PDF…", "dataPrivacyTitle": "Data privacy", "dataPrivacySubtitle": "Manage local encryption and cloud sync", - "vitalScanTitle": "Vital scan", - "vitalAlignFinger": "Align index finger with rear camera and flash for PPG reading.", + "vitalScanTitle": "Vital entry", + "vitalAlignFinger": "Bystander-entered values are relayed to 108/112 dispatchers. Not a medical device.", "settingsLanguage": "Language", "settingsLanguageSubtitle": "UI, triage prompts, and voice output", "incidentAssistantAnalyzed": "Scene note: frontal impact logged. Check for smoke or fire.", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index a4c1e85..01d21cf 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -50,8 +50,8 @@ "blackBoxSnack": "हस्ताक्षरित PDF बन रहा है…", "dataPrivacyTitle": "डेटा गोपनीयता", "dataPrivacySubtitle": "एन्क्रिप्शन और सिंक", - "vitalScanTitle": "वाइटल स्कैन", - "vitalAlignFinger": "PPG के लिए उंगली कैमरे और फ्लैश पर रखें।", + "vitalScanTitle": "वाइटल प्रविष्टि", + "vitalAlignFinger": "दर्शक द्वारा दर्ज मान 108/112 डिस्पैचर को भेजे जाते हैं। यह चिकित्सा उपकरण नहीं है।", "settingsLanguage": "भाषा", "settingsLanguageSubtitle": "इंटरफ़ेस, ट्राइएज और आवाज़", "incidentAssistantAnalyzed": "दृश्य: अगला प्रहार दर्ज। धुआँ या आग जाँचें।", diff --git a/lib/services/ai_triage_service.dart b/lib/services/ai_triage_service.dart index f3b3891..cda926e 100644 --- a/lib/services/ai_triage_service.dart +++ b/lib/services/ai_triage_service.dart @@ -270,11 +270,19 @@ class AiTriageService { int severityHint = 3, }) async { final locationString = '${location.latitude},${location.longitude}'; - final ctx = transcript.trim().isEmpty - ? (isBystander - ? 'Bystander reporting roadside emergency' - : 'Emergency SOS triggered') - : transcript; + String ctx; + if (transcript.trim().isNotEmpty) { + ctx = transcript; + } else if (isBystander) { + ctx = 'Bystander reporting roadside emergency at $locationString. ' + 'Severity hint from sensors: $severityHint/5. ' + 'Unknown number of victims. Assess and triage.'; + } else { + ctx = 'Automatic crash SOS triggered at $locationString. ' + 'Accelerometer severity: $severityHint/5. ' + 'Driver may be incapacitated — no verbal input available. ' + 'Assume worst case: possible unconscious victim in vehicle.'; + } return triageEmergency( audioTranscript: ctx, diff --git a/lib/services/emergency_orchestrator.dart b/lib/services/emergency_orchestrator.dart index 5527d6e..8ab4cea 100644 --- a/lib/services/emergency_orchestrator.dart +++ b/lib/services/emergency_orchestrator.dart @@ -481,8 +481,8 @@ class EmergencyOrchestrator extends StateNotifier { 'mesh', meshOk ? DispatchChannelLifecycle.success : DispatchChannelLifecycle.failed, meshOk - ? 'Mesh beacon active — nearby app users can detect you ✓' - : 'Mesh did not start — Bluetooth off, unsupported, or failed.', + ? 'BLE beacon active — nearby RoadSOS users within ~30m can detect you ✓' + : 'BLE beacon did not start — Bluetooth off, unsupported, or failed.', ); return meshOk; }); @@ -572,7 +572,11 @@ class EmergencyOrchestrator extends StateNotifier { final nearbyFuture = guard( id: 'nearby_services', - future: Future.delayed(const Duration(seconds: 3), () => true), + future: _broadcastToNearbyServices( + incidentId: state.incidentId ?? '', + location: location, + triage: triage, + ), fallback: false, timeoutDetail: 'Nearby services broadcast timed out.', failureDetail: 'Nearby services broadcast failed.', @@ -581,8 +585,8 @@ class EmergencyOrchestrator extends StateNotifier { 'nearby_services', ok ? DispatchChannelLifecycle.success : DispatchChannelLifecycle.failed, ok - ? 'Emergency alert broadcasted to nearby facilities and responders ✓' - : 'Could not complete nearby services broadcast.', + ? 'Emergency alert sent to Supabase + nearby responders ✓' + : 'Nearby broadcast skipped — no Supabase session or network.', ); return ok; }); @@ -650,6 +654,8 @@ class EmergencyOrchestrator extends StateNotifier { } // Phase 7: post-dispatch voice briefing — the driver hears what was sent. + // CRITICAL: tell the driver whether SMS actually succeeded. Never falsely + // reassure an injured person that help is coming when dispatch failed. if (state.wasInDrivingMode) { final voice = _ref.read(voiceAssistantServiceProvider); unawaited(voice.speakTriageSummary( @@ -657,6 +663,7 @@ class EmergencyOrchestrator extends StateNotifier { services: triage.requiredServices, locationCoords: '${location.latitude.toStringAsFixed(2)}, ' '${location.longitude.toStringAsFixed(2)}', + smsSucceeded: anyConfirmed, )); } } @@ -693,7 +700,7 @@ class EmergencyOrchestrator extends StateNotifier { return const [ DispatchChannelRow( id: 'mesh', - title: 'Mesh beacon (BLE)', + title: 'BLE beacon (nearby alert)', lifecycle: DispatchChannelLifecycle.pending, detail: 'Waiting…', ), @@ -717,7 +724,7 @@ class EmergencyOrchestrator extends StateNotifier { ), DispatchChannelRow( id: 'nearby_services', - title: 'Nearby Services', + title: 'Nearby services (Supabase)', lifecycle: DispatchChannelLifecycle.pending, detail: 'Waiting…', ), @@ -799,23 +806,61 @@ class EmergencyOrchestrator extends StateNotifier { void cancelSOS() => cancelSos(); Future _callEmergencyContact() async { + // Always attempt to dial 112 (India ERSS) first — this is the real + // emergency dispatch path that connects to police/ambulance/fire. + // The user's saved contact is secondary (family notification). + if (!kIsWeb) { + await _dialNumber('112', desc: 'ERSS 112'); + } + final profile = _ref.read(userProfileProvider); final contact = profile.emergencyContact.trim(); if (contact.isEmpty) { - appLog.w('[Orchestrator] No emergency contact found to call.'); + appLog.w('[Orchestrator] No personal emergency contact — 112 dialed.'); return; } - final uri = Uri.parse('tel:$contact'); + await _dialNumber(contact, desc: 'emergency contact'); + } + + Future _dialNumber(String number, {required String desc}) async { + final uri = Uri.parse('tel:$number'); try { if (await canLaunchUrl(uri)) { - appLog.i('[Orchestrator] Initiating automated call to $contact'); + appLog.i('[Orchestrator] Dialing $desc ($number)'); await launchUrl(uri); } else { - appLog.w('[Orchestrator] Could not launch dialer for $contact'); + appLog.w('[Orchestrator] Could not launch dialer for $desc'); } } catch (e, st) { - appLog.e('[Orchestrator] Error launching dialer', error: e, stackTrace: st); + appLog.e('[Orchestrator] Error dialing $desc', error: e, stackTrace: st); + } + } + + Future _broadcastToNearbyServices({ + required String incidentId, + required LocationFix location, + required TriageResult triage, + }) async { + if (!_hasSupabaseSession()) return false; + try { + final client = Supabase.instance.client; + final userId = client.auth.currentUser?.id; + await client.from('sos_broadcasts').insert({ + 'incident_id': incidentId, + 'user_id': userId, + 'latitude': location.latitude, + 'longitude': location.longitude, + 'severity': triage.severityLevel, + 'services_needed': triage.requiredServices.join(','), + 'status': 'active', + 'created_at': DateTime.now().toUtc().toIso8601String(), + }); + appLog.i('[Orchestrator] SOS broadcast inserted into Supabase'); + return true; + } catch (e, st) { + appLog.w('[Orchestrator] Nearby services broadcast failed', error: e, stackTrace: st); + return false; } } diff --git a/lib/services/gemini_http.dart b/lib/services/gemini_http.dart deleted file mode 100644 index 0619104..0000000 --- a/lib/services/gemini_http.dart +++ /dev/null @@ -1,62 +0,0 @@ -import 'dart:convert'; - -import 'package:http/http.dart' as http; - -/// Calls Google Gemini REST API ([Gemini API](https://ai.google.dev/gemini-api/docs)). -/// Single shared entry point — avoids duplicate HTTP logic and duplicate native deps. -Future generateGeminiFlashText({ - required String apiKey, - required String prompt, - String model = 'gemini-2.0-flash', - Duration timeout = const Duration(seconds: 20), -}) async { - final uri = Uri.https( - 'generativelanguage.googleapis.com', - 'v1beta/models/$model:generateContent', - {'key': apiKey}, - ); - final body = jsonEncode({ - 'contents': [ - { - 'parts': [ - {'text': prompt}, - ], - }, - ], - 'generationConfig': { - 'temperature': 0.3, - 'maxOutputTokens': 256, - }, - }); - - final response = await http - .post( - uri, - headers: {'Content-Type': 'application/json'}, - body: body, - ) - .timeout(timeout); - - if (response.statusCode != 200) { - throw Exception('Gemini HTTP ${response.statusCode}: ${response.body}'); - } - - final decoded = jsonDecode(response.body) as Map; - return _extractGeminiText(decoded); -} - -String _extractGeminiText(Map decoded) { - final candidates = decoded['candidates']; - if (candidates is! List || candidates.isEmpty) return ''; - final first = candidates.first; - if (first is! Map) return ''; - final content = first['content']; - if (content is! Map) return ''; - final parts = content['parts']; - if (parts is! List) return ''; - final buf = StringBuffer(); - for (final p in parts) { - if (p is Map && p['text'] is String) buf.write(p['text'] as String); - } - return buf.toString(); -} diff --git a/lib/services/india_government_crash_contribution_service.dart b/lib/services/india_government_crash_contribution_service.dart deleted file mode 100644 index 7f249a1..0000000 --- a/lib/services/india_government_crash_contribution_service.dart +++ /dev/null @@ -1,100 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter_dotenv/flutter_dotenv.dart'; -import 'package:http/http.dart' as http; - -/// Extension point for **anonymized** crash / hazard density aligned with -/// Indian road-safety reporting ecosystems (e.g. MoRTH programmes such as -/// **iRASTE** — Intelligent Solutions for Road Safety through Technology and -/// Engineering). -/// -/// There is **no stable public REST API** documented here; production use -/// requires outreach to MoRTH / NIC / programme office for **data-sharing MOU** -/// or approved aggregator endpoints. Until then, this service no-ops unless -/// `GOVERNMENT_CRASH_CONTRIBUTION_URL` is set. -/// -/// Privacy: only coarse grid identifiers and aggregated counts — no user id, -/// phone, vehicle id, or exact GPS beyond the chosen grid resolution. -class IndiaGovernmentCrashContributionService { - /// H3-like concept without adding a dependency: fixed-step grid from WGS84. - /// Caller rounds lat/lon before sending (e.g. 3 decimals ~ 111 m). - static ({String gridId, double lat, double lon}) anonymizedGridCell({ - required double latitude, - required double longitude, - int decimals = 3, - }) { - final rLat = double.parse(latitude.toStringAsFixed(decimals)); - final rLon = double.parse(longitude.toStringAsFixed(decimals)); - final gridId = '${rLat}_$rLon'; - return (gridId: gridId, lat: rLat, lon: rLon); - } - - Future submitHeatmapAggregates(List cells) async { - final base = dotenv.maybeGet('GOVERNMENT_CRASH_CONTRIBUTION_URL')?.trim(); - if (base == null || base.isEmpty) return; - if (cells.isEmpty) return; - - final uri = Uri.parse(base); - final token = dotenv.maybeGet('GOVERNMENT_CRASH_CONTRIBUTION_TOKEN')?.trim(); - - final headers = { - 'Content-Type': 'application/json', - if (token != null && token.isNotEmpty) 'Authorization': 'Bearer $token', - }; - - final payload = jsonEncode({ - 'schema_version': 1, - 'project': 'roadsos_anonymized_density', - 'cells': cells.map((c) => c.toJson()).toList(), - }); - - final response = await http - .post(uri, headers: headers, body: payload) - .timeout(const Duration(seconds: 15)); - if (response.statusCode < 200 || response.statusCode >= 300) { - throw HttpContributionException( - 'Contribution HTTP ${response.statusCode}: ${response.body}', - ); - } - } -} - -class CrashDensityCell { - CrashDensityCell({ - required this.gridId, - required this.latitude, - required this.longitude, - required this.reportCount, - required this.windowStartUtc, - required this.windowEndUtc, - required this.maxSeverityBucket, - }); - - final String gridId; - final double latitude; - final double longitude; - final int reportCount; - final String windowStartUtc; - final String windowEndUtc; - - /// 1–5 coarse bucket; no free-text incident detail. - final int maxSeverityBucket; - - Map toJson() => { - 'grid_id': gridId, - 'latitude': latitude, - 'longitude': longitude, - 'report_count': reportCount, - 'window_start_utc': windowStartUtc, - 'window_end_utc': windowEndUtc, - 'max_severity_bucket': maxSeverityBucket, - }; -} - -class HttpContributionException implements Exception { - HttpContributionException(this.message); - final String message; - - @override - String toString() => 'HttpContributionException: $message'; -} diff --git a/lib/services/multi_agent_coordinator.dart b/lib/services/multi_agent_coordinator.dart deleted file mode 100644 index e7f4280..0000000 --- a/lib/services/multi_agent_coordinator.dart +++ /dev/null @@ -1,153 +0,0 @@ -import 'dart:async'; - -import '../logging/app_log.dart'; - -/// Phase 5 — Multi-agent orchestration: observable, interruptible task graph. -/// -/// The dispatch pipeline already runs channels in parallel via Future.wait(). -/// This coordinator makes that explicit with per-agent lifecycle tracking, -/// real-time status streaming, and individual cancellation. -/// -/// Each [AgentTask] is: -/// - Observable: status stream updates in real-time -/// - Interruptible: [cancel] stops the task before completion -/// - Validated: the ValidationAgent checks each result post-completion -/// -/// Agent types in RoadSOS: -/// Planning: EmergencyOrchestrator (decides what to dispatch) -/// Execution: MeshAgent, SmsAgent, FamilyLinkAgent, LocalLogAgent -/// Validation: TriageValidationAgent (safety gate before dispatch) -/// Monitoring: HeartbeatAgent (30s ping from background service) -enum AgentStatus { pending, running, completed, failed, cancelled } - -/// A single observable unit of work in the dispatch pipeline. -class AgentTask { - final String id; - final String displayName; - AgentStatus status; - String? resultSummary; - String? errorDetail; - DateTime? startedAt; - DateTime? completedAt; - - AgentTask({ - required this.id, - required this.displayName, - }) : status = AgentStatus.pending; - - Duration? get duration => (startedAt != null && completedAt != null) - ? completedAt!.difference(startedAt!) - : null; - - bool get isTerminal => - status == AgentStatus.completed || - status == AgentStatus.failed || - status == AgentStatus.cancelled; -} - -/// Orchestrates a group of [AgentTask]s in parallel with real-time status updates. -/// -/// Usage: -/// ```dart -/// final coord = MultiAgentCoordinator(); -/// final task1 = coord.register('ble_mesh', 'BLE Mesh Beacon'); -/// final task2 = coord.register('sms', 'Emergency SMS'); -/// coord.start(); -/// -/// coord.run(task1, () => meshService.startBroadcasting(...)); -/// coord.run(task2, () => meshService.triggerSmsFallback(...)); -/// -/// await coord.awaitAll(); -/// ``` -class MultiAgentCoordinator { - final _tasks = {}; - final _controller = StreamController.broadcast(); - bool _aborted = false; - - Stream get statusStream => _controller.stream; - - List get tasks => List.unmodifiable(_tasks.values); - - AgentTask register(String id, String displayName) { - final task = AgentTask(id: id, displayName: displayName); - _tasks[id] = task; - return task; - } - - /// Run [work] under [task]'s lifecycle tracking. - /// Emits status updates to [statusStream] at each transition. - Future run(AgentTask task, Future Function() work) async { - if (_aborted) { - _transition(task, AgentStatus.cancelled, error: 'Coordinator aborted'); - return null; - } - - _transition(task, AgentStatus.running); - - try { - final result = await work(); - _transition(task, AgentStatus.completed); - return result; - } catch (e, st) { - _transition(task, AgentStatus.failed, error: e.toString()); - appLog.w('[Agent] ${task.displayName} failed', error: e, stackTrace: st); - return null; - } - } - - void _transition(AgentTask task, AgentStatus next, {String? error}) { - task.status = next; - if (next == AgentStatus.running) task.startedAt = DateTime.now(); - if (task.isTerminal) task.completedAt = DateTime.now(); - if (error != null) task.errorDetail = error; - - final dur = task.duration; - appLog.d( - '[Agent] ${task.displayName}: ${next.name}' - '${dur != null ? " (${dur.inMilliseconds}ms)" : ""}', - ); - - if (!_controller.isClosed) _controller.add(task); - } - - /// Update the display summary of a completed task. - void setSummary(AgentTask task, String summary) { - task.resultSummary = summary; - if (!_controller.isClosed) _controller.add(task); - } - - /// Wait for all registered tasks to reach a terminal state. - Future awaitAll({Duration timeout = const Duration(seconds: 30)}) async { - final deadline = DateTime.now().add(timeout); - while (!_tasks.values.every((t) => t.isTerminal)) { - if (DateTime.now().isAfter(deadline)) { - for (final t in _tasks.values.where((t) => !t.isTerminal)) { - _transition(t, AgentStatus.failed, error: 'Timeout'); - } - break; - } - await Future.delayed(const Duration(milliseconds: 100)); - } - } - - /// Abort all pending/running tasks (e.g., user cancelled the SOS). - void abort() { - _aborted = true; - for (final t in _tasks.values.where((t) => !t.isTerminal)) { - _transition(t, AgentStatus.cancelled, error: 'SOS cancelled by user'); - } - appLog.i('[Coordinator] All agents aborted.'); - } - - void dispose() { - if (!_controller.isClosed) _controller.close(); - } - - /// Summary stats for the activity log. - String get summaryLine { - final done = _tasks.values.where((t) => t.status == AgentStatus.completed).length; - final failed = _tasks.values.where((t) => t.status == AgentStatus.failed).length; - final total = _tasks.length; - return '$done/$total agents completed${failed > 0 ? ", $failed failed" : ""}.'; - } -} diff --git a/lib/services/scene_security_service.dart b/lib/services/scene_security_service.dart deleted file mode 100644 index 30929d8..0000000 --- a/lib/services/scene_security_service.dart +++ /dev/null @@ -1,68 +0,0 @@ -import 'dart:convert'; -import 'package:crypto/crypto.dart'; -import 'package:cryptography/cryptography.dart'; - -class SceneSecurityService { - /// Generates a 128-bit key based on rounded coordinates and hourly timestamp. - /// This ensures 'Spatial Privacy' - only people in the same region at the same time can decrypt. - static String generateSceneKey(double lat, double lng) { - // Round to ~1.1km precision (2 decimal places) - final String spatialHash = "${lat.toStringAsFixed(2)}:${lng.toStringAsFixed(2)}"; - final int hourStamp = DateTime.now().millisecondsSinceEpoch ~/ (1000 * 60 * 60); - - final bytes = utf8.encode("$spatialHash:$hourStamp"); - final digest = sha256.convert(bytes); - - return digest.toString().substring(0, 32); // Use first 32 chars for AES-256 - } - - static final _aead = AesGcm.with256bits(); - - /// Encrypts [data] with AES-GCM using a random nonce. - /// - /// Output format (base64, URL-safe): - /// `v1..` - static Future encryptPayload(String data, String keyString) async { - final keyBytes = utf8.encode(keyString.substring(0, 32)); - final secretKey = SecretKey(keyBytes); - final nonce = _aead.newNonce(); - final secretBox = await _aead.encrypt( - utf8.encode(data), - secretKey: secretKey, - nonce: nonce, - ); - final nonceB64 = base64UrlEncode(secretBox.nonce); - final cipherB64 = base64UrlEncode( - [...secretBox.cipherText, ...secretBox.mac.bytes], - ); - return 'v1.$nonceB64.$cipherB64'; - } - - /// Attempts to decrypt an AES-GCM payload created by [encryptPayload]. - /// - /// Returns `null` if decryption fails (wrong key / corrupted / wrong format). - static Future decryptPayload(String encoded, String keyString) async { - try { - final parts = encoded.split('.'); - if (parts.length != 3 || parts[0] != 'v1') return null; - final nonce = base64Url.decode(parts[1]); - final combined = base64Url.decode(parts[2]); - if (combined.length < 16) return null; - final cipherText = combined.sublist(0, combined.length - 16); - final macBytes = combined.sublist(combined.length - 16); - - final keyBytes = utf8.encode(keyString.substring(0, 32)); - final secretKey = SecretKey(keyBytes); - final secretBox = SecretBox( - cipherText, - nonce: nonce, - mac: Mac(macBytes), - ); - - final clear = await _aead.decrypt(secretBox, secretKey: secretKey); - return utf8.decode(clear); - } catch (_) { - return null; - } - } -} diff --git a/lib/services/structured_sms_service.dart b/lib/services/structured_sms_service.dart index 9bad962..d91a51d 100644 --- a/lib/services/structured_sms_service.dart +++ b/lib/services/structured_sms_service.dart @@ -6,6 +6,7 @@ import 'gemma_local_service.dart'; import 'india_emergency_routing.dart'; import 'location_service.dart'; import 'user_profile_service.dart'; +import 'vital_signs_service.dart'; /// Hard cap for the SMS body — 1 standard SMS segment is 160 GSM-7 chars. /// We aim well below this (≈150) so carrier headers / encoding shifts do not @@ -55,7 +56,7 @@ class StructuredSmsService { final servicesShort = _shortServices(triage.requiredServices); final profileShort = _shortProfile(profile); - final vitals = _vitalsPlaceholder(); + final vitals = _resolveVitals(); final deterministic = _buildDeterministic( stateCode: stateCode, @@ -144,9 +145,20 @@ class StructuredSmsService { return 'B$btShort A:$allergyShort'; } - /// Vitals placeholder until [VitalSignsService] exposes a live snapshot. - /// Question marks tell the dispatcher "unknown — please probe on callback". - String _vitalsPlaceholder() => 'C?B?Bl?'; + /// Returns vitals from VitalSignsService if a bystander recorded them, + /// otherwise question marks tell the dispatcher "unknown — please probe". + String _resolveVitals() { + try { + final vitals = _ref.read(vitalSignsProvider); + if (vitals != null) { + final hr = vitals.bpm; + final rr = vitals.respiratoryRate; + final spo2 = vitals.bloodOxygen.toStringAsFixed(0); + return 'HR${hr}RR${rr}O$spo2'; + } + } catch (_) {} + return 'C?B?Bl?'; + } String _truncateGsm7Safe(String input, int maxLen) { final cleaned = input.replaceAll(RegExp(r'\s+'), ' ').trim(); diff --git a/lib/services/voice_assistant_service.dart b/lib/services/voice_assistant_service.dart index 28809ef..2b67921 100644 --- a/lib/services/voice_assistant_service.dart +++ b/lib/services/voice_assistant_service.dart @@ -68,14 +68,16 @@ class VoiceAssistantService { /// Announces the completed triage after dispatch — post-SOS voice briefing. /// - /// Spoken immediately after the pipeline completes so the driver/victim - /// knows what was dispatched without needing to look at the screen. + /// [smsSucceeded] controls whether the announcement says "dispatched" or + /// warns the user to dial manually. An injured driver relying on audio must + /// never be falsely reassured that help is on the way when SMS failed. Future speakTriageSummary({ required int severity, required List services, required String locationCoords, + bool smsSucceeded = false, }) async { - final msg = _localizedTriageSummary(severity, services, locationCoords); + final msg = _localizedTriageSummary(severity, services, locationCoords, smsSucceeded); await _tts.setSpeechRate(0.46); await speak(msg); await _tts.setSpeechRate(0.48); @@ -89,6 +91,10 @@ class VoiceAssistantService { return 'அவசர SOS $seconds வினாடிகளில். நிறுத்த "நிறுத்து" என்று சொல்லுங்கள்.'; case 'te': return 'అత్యవసర SOS $seconds సెకన్లలో. ఆపడానికి "ఆపు" అని చెప్పండి.'; + case 'bn': + return 'জরুরি SOS $seconds সেকেন্ডে। বাতিল করতে "থামো" বলুন।'; + case 'mr': + return 'आणीबाणी SOS $seconds सेकंदात। थांबवण्यासाठी "थांब" म्हणा।'; default: return 'Emergency SOS in $seconds seconds. $locationHint. ' 'Say "cancel" to stop.'; @@ -99,15 +105,48 @@ class VoiceAssistantService { int severity, List services, String location, + bool smsSucceeded, ) { final svcText = services.map(_serviceLabel).join(' and '); switch (_locale.languageCode) { case 'hi': - return 'SOS भेजा गया। $svcText बुलाया गया। गंभीरता स्तर $severity। ' - 'स्थान: $location। शांत रहें।'; + if (smsSucceeded) { + return 'SOS भेजा गया। $svcText बुलाया गया। गंभीरता स्तर $severity। ' + 'स्थान: $location। शांत रहें।'; + } + return 'SOS संदेश भेजने में विफल। कृपया 112 डायल करें। ' + 'गंभीरता स्तर $severity। स्थान: $location।'; + case 'bn': + if (smsSucceeded) { + return 'SOS পাঠানো হয়েছে। $svcText অনুরোধ করা হয়েছে। তীব্রতা $severity। ' + 'অবস্থান: $location। শান্ত থাকুন।'; + } + return 'SOS বার্তা পাঠানো যায়নি। অনুগ্রহ করে 112 ডায়াল করুন।'; + case 'mr': + if (smsSucceeded) { + return 'SOS पाठवला। $svcText मागवला। तीव्रता $severity। ' + 'स्थान: $location। शांत रहा।'; + } + return 'SOS संदेश पाठवता आला नाही। कृपया 112 डायल करा।'; + case 'ta': + if (smsSucceeded) { + return 'SOS அனுப்பப்பட்டது. $svcText கோரப்பட்டது. தீவிரம் $severity. ' + 'இடம்: $location. அமைதியாக இருங்கள்.'; + } + return 'SOS செய்தி அனுப்ப முடியவில்லை. 112 அழைக்கவும்.'; + case 'te': + if (smsSucceeded) { + return 'SOS పంపబడింది. $svcText అభ్యర్థించబడింది. తీవ్రత $severity. ' + 'స్థానం: $location. ప్రశాంతంగా ఉండండి.'; + } + return 'SOS సందేశం పంపడం విఫలమైంది. దయచేసి 112 డయల్ చేయండి.'; default: - return 'SOS dispatched. $svcText requested. Severity level $severity. ' - 'Location: $location. Stay calm and do not move if injured.'; + if (smsSucceeded) { + return 'SOS dispatched. $svcText requested. Severity level $severity. ' + 'Location: $location. Stay calm and do not move if injured.'; + } + return 'SOS message could not be sent automatically. ' + 'Please dial 112 now. Severity level $severity. Location: $location.'; } } diff --git a/lib/ui/bystander_radar.dart b/lib/ui/bystander_radar.dart deleted file mode 100644 index 6e975d5..0000000 --- a/lib/ui/bystander_radar.dart +++ /dev/null @@ -1,173 +0,0 @@ -import 'dart:math' as math; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../services/mesh_network_service.dart'; - -class BystanderRadar extends ConsumerStatefulWidget { - const BystanderRadar({super.key}); - - @override - ConsumerState createState() => _BystanderRadarState(); -} - -class _BystanderRadarState extends ConsumerState - with SingleTickerProviderStateMixin { - late AnimationController _controller; - - @override - void initState() { - super.initState(); - _controller = AnimationController( - vsync: this, - duration: const Duration(seconds: 4), - )..repeat(); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final beaconsStream = ref - .watch(meshNetworkServiceProvider) - .discoveredBeacons; - - return StreamBuilder>( - stream: beaconsStream, - initialData: const [], - builder: (context, snapshot) { - final beacons = snapshot.data ?? const []; - return Column( - children: [ - Stack( - alignment: Alignment.center, - children: [ - // Radar Background Rings - CustomPaint( - size: const Size(200, 200), - painter: _RadarPainter(_controller), - ), - // Radar Pulse - // ⚡ Bolt Optimization: Use RotationTransition with a static child to prevent - // the expensive SweepGradient shader from being rebuilt on every frame. - RotationTransition( - turns: _controller, - child: const CustomPaint( - size: Size(200, 200), - painter: _SweepPainter(), - ), - ), - ..._buildBeaconDots(beacons), - const Icon(Icons.my_location, color: Colors.blue, size: 24), - ], - ), - const SizedBox(height: 16), - Text( - beacons.isEmpty - ? 'SCANNING (NO PEERS)' - : 'PEERS DETECTED: ${beacons.length}', - style: const TextStyle( - color: Colors.blue, - fontSize: 10, - fontWeight: FontWeight.bold, - letterSpacing: 2, - ), - ), - ], - ); - }, - ); - } - - List _buildBeaconDots(List ids) { - final out = []; - const center = 100.0; - const maxR = 78.0; - for (final id in ids) { - final h = id.hashCode; - final angle = ((h % 360) * math.pi) / 180.0; - final r = (math.sqrt(((h >> 8).abs() % 1000) / 1000.0) * maxR).clamp( - 18.0, - maxR, - ); - final x = center + math.cos(angle) * r; - final y = center + math.sin(angle) * r; - out.add(Positioned(left: x, top: y, child: const _IncidentDot())); - } - return out; - } -} - -class _RadarPainter extends CustomPainter { - final Animation animation; - _RadarPainter(this.animation) : super(repaint: animation); - - @override - void paint(Canvas canvas, Size size) { - final paint = Paint() - ..color = Colors.blue.withValues(alpha: 0.1) - ..style = PaintingStyle.stroke - ..strokeWidth = 1.0; - - final center = Offset(size.width / 2, size.height / 2); - canvas.drawCircle(center, size.width * 0.2, paint); - canvas.drawCircle(center, size.width * 0.35, paint); - canvas.drawCircle(center, size.width * 0.5, paint); - } - - @override - bool shouldRepaint(covariant CustomPainter oldDelegate) => false; -} - -class _SweepPainter extends CustomPainter { - const _SweepPainter(); - - @override - void paint(Canvas canvas, Size size) { - final center = Offset(size.width / 2, size.height / 2); - final radius = size.width / 2; - - final rect = Rect.fromCircle(center: center, radius: radius); - final gradient = SweepGradient( - startAngle: 0, - endAngle: math.pi * 2, - colors: [ - Colors.blue.withValues(alpha: 0), - Colors.blue.withValues(alpha: 0.5), - Colors.blue.withValues(alpha: 0), - ], - stops: const [0.0, 0.5, 1.0], - ); - - final paint = Paint() - ..shader = gradient.createShader(rect) - ..style = PaintingStyle.fill; - - canvas.drawCircle(center, radius, paint); - } - - @override - bool shouldRepaint(covariant CustomPainter oldDelegate) => false; -} - -class _IncidentDot extends StatelessWidget { - const _IncidentDot(); - - @override - Widget build(BuildContext context) { - return Container( - width: 12, - height: 12, - decoration: const BoxDecoration( - color: Colors.red, - shape: BoxShape.circle, - boxShadow: [ - BoxShadow(color: Colors.red, blurRadius: 10, spreadRadius: 2), - ], - ), - ); - } -} diff --git a/lib/ui/dashboard.dart b/lib/ui/dashboard.dart index d8e7122..70352e9 100644 --- a/lib/ui/dashboard.dart +++ b/lib/ui/dashboard.dart @@ -424,7 +424,7 @@ class _DashboardScreenState extends ConsumerState icon: Icons.camera_enhance, color: const Color(0xFFF59220), title: 'Capture Scene', - subtitle: 'Document crash with AI-powered photo analysis', + subtitle: 'Photo + Gemma 4 vision triage + witness interview', onTap: () => Navigator.push( context, MaterialPageRoute( @@ -506,8 +506,8 @@ class _DashboardScreenState extends ConsumerState context, icon: Icons.monitor_heart, color: const Color(0xFFE8281A), - title: 'Vital Scan', - subtitle: 'Check heart rate & oxygen saturation', + title: 'Vital Entry', + subtitle: 'Log pulse, breathing rate & SpO₂ for dispatcher relay', onTap: () => Navigator.push( context, MaterialPageRoute(builder: (_) => const VitalScanScreen()), @@ -534,8 +534,8 @@ class _DashboardScreenState extends ConsumerState context, icon: Icons.forum, color: const Color(0xFF9C27B0), - title: 'Mesh Chat', - subtitle: 'Offline Bluetooth messaging — no signal needed', + title: 'BLE Chat', + subtitle: 'Nearby Bluetooth messaging (~30m range, no internet needed)', onTap: () => Navigator.push( context, MaterialPageRoute(builder: (_) => const MeshChatScreen()), diff --git a/lib/ui/incident_reporting_screen.dart b/lib/ui/incident_reporting_screen.dart index 402782f..d28bc14 100644 --- a/lib/ui/incident_reporting_screen.dart +++ b/lib/ui/incident_reporting_screen.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:typed_data'; import 'package:flutter/foundation.dart' show kIsWeb; @@ -5,8 +6,13 @@ import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import 'package:roadsos/l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../services/ai_triage_service.dart'; import '../services/app_locale_controller.dart'; +import '../services/camera_triage_service.dart'; +import '../services/location_service.dart'; +import '../services/emergency_orchestrator.dart'; import '../services/roadsos_assistant_service.dart'; + class IncidentReportingScreen extends ConsumerStatefulWidget { const IncidentReportingScreen({super.key}); @@ -21,6 +27,8 @@ class _IncidentReportingScreenState final _picker = ImagePicker(); Uint8List? _sceneImageBytes; bool _sceneCaptureBusy = false; + bool _analysisRunning = false; + String? _analysisResult; @override Widget build(BuildContext context) { @@ -40,11 +48,71 @@ class _IncidentReportingScreenState child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildSectionHeader('MULTIMODAL: DIGITAL TWIN'), + // Emergency quick-action bar — most useful actions first + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: Colors.red.withValues(alpha: 0.25)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'QUICK ACTIONS', + style: TextStyle(color: Colors.red, fontSize: 10, fontWeight: FontWeight.w900, letterSpacing: 1.5), + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: SizedBox( + height: 48, + child: ElevatedButton.icon( + onPressed: _sceneCaptureBusy ? null : _captureScene, + icon: const Icon(Icons.camera_alt, size: 18), + label: const Text('PHOTO', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w800)), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFF59220), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: SizedBox( + height: 48, + child: ElevatedButton.icon( + onPressed: () { + final sosState = ref.read(emergencyOrchestratorProvider); + if (sosState.phase == SOSPhase.idle) { + ref.read(emergencyOrchestratorProvider.notifier).startSos(isBystander: true); + } + }, + icon: const Icon(Icons.emergency, size: 18), + label: const Text('SOS', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w800)), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + ), + ), + ], + ), + ], + ), + ), + const SizedBox(height: 20), + _buildSectionHeader('SCENE PHOTO'), const SizedBox(height: 12), _buildSceneCaptureCard(l10n), const SizedBox(height: 32), - _buildSectionHeader('AI INTERVIEW: SITUATIONAL NUANCE'), + _buildSectionHeader('WITNESS INTERVIEW (GEMMA 4)'), const SizedBox(height: 12), _buildVoiceInterviewCard(assistantState, l10n), const SizedBox(height: 32), @@ -118,16 +186,41 @@ class _IncidentReportingScreenState backgroundColor: _sceneImageBytes != null ? Colors.green : Colors.blue, ), ), - if (_sceneImageBytes != null) + if (_sceneImageBytes != null && _analysisRunning) + const Padding( + padding: EdgeInsets.only(top: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2)), + SizedBox(width: 8), + Text( + 'Gemma 4 analyzing scene…', + style: TextStyle(color: Colors.orange, fontSize: 10, fontWeight: FontWeight.bold), + ), + ], + ), + ), + if (_sceneImageBytes != null && !_analysisRunning && _analysisResult != null) Padding( padding: const EdgeInsets.only(top: 12), child: Text( - 'Photo attached to this report (not auto-analyzed in this build).', + _analysisResult!, style: const TextStyle( color: Colors.green, fontSize: 10, fontWeight: FontWeight.bold), textAlign: TextAlign.center, ), ), + if (_sceneImageBytes != null && !_analysisRunning && _analysisResult == null) + const Padding( + padding: EdgeInsets.only(top: 12), + child: Text( + 'Photo attached — tap to send for AI analysis.', + style: TextStyle( + color: Colors.green, fontSize: 10, fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), + ), ], ), ), @@ -182,10 +275,63 @@ class _IncidentReportingScreenState } } - if (!kIsWeb && mounted) { + if (!kIsWeb && mounted && _sceneImageBytes != null) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Scene photo attached.')), + const SnackBar(content: Text('Scene photo attached — analyzing with Gemma 4…')), + ); + _analyzeWithGemma4(); + } + } + + Future _analyzeWithGemma4() async { + if (_sceneImageBytes == null) return; + setState(() { + _analysisRunning = true; + _analysisResult = null; + }); + + try { + final base64Jpeg = base64Encode(_sceneImageBytes!); + final photo = CapturedScenePhoto( + base64Jpeg: base64Jpeg, + sizeBytes: _sceneImageBytes!.length, + capturedAt: DateTime.now().toUtc(), ); + + final aiTriage = ref.read(aiTriageServiceProvider); + final lang = ref.read(appLocaleProvider).languageCode; + + LocationFix? loc; + try { + loc = await ref.read(locationServiceProvider).getCurrentLocation() + .timeout(const Duration(seconds: 8)); + } catch (_) {} + + final locationStr = loc != null + ? '${loc.latitude},${loc.longitude}' + : 'unknown'; + + final result = await aiTriage.triageWithScenePhoto( + audioTranscript: 'Bystander scene photo analysis', + locationString: locationStr, + accelerometerSeverityHint: 3, + scenePhoto: photo, + languageCode: lang, + ).timeout(const Duration(seconds: 15)); + + if (!mounted) return; + setState(() { + _analysisRunning = false; + _analysisResult = 'Gemma 4 analysis: Severity ${result.severityLevel}/5 — ' + '${result.requiredServices.join(", ")} needed. ' + '(${result.sourceLabel})'; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _analysisRunning = false; + _analysisResult = 'AI analysis unavailable — photo saved for manual review.'; + }); } } diff --git a/lib/ui/vital_scan_screen.dart b/lib/ui/vital_scan_screen.dart index 7c8a923..df5ebbe 100644 --- a/lib/ui/vital_scan_screen.dart +++ b/lib/ui/vital_scan_screen.dart @@ -102,7 +102,8 @@ class _VitalScanScreenState extends ConsumerState with TickerPr ], const SizedBox(height: 24), Text( - AppLocalizations.of(context)!.vitalAlignFinger, + 'Bystander-entered values are relayed to 108/112 dispatchers. ' + 'This is not a medical device — use a pulse oximeter if available.', textAlign: TextAlign.center, style: TextStyle(color: Colors.white.withValues(alpha: 0.3), fontSize: 12), ), diff --git a/supabase/functions/sms-dispatch/index.ts b/supabase/functions/sms-dispatch/index.ts index a4ac2b5..04a7eaf 100644 --- a/supabase/functions/sms-dispatch/index.ts +++ b/supabase/functions/sms-dispatch/index.ts @@ -58,7 +58,9 @@ function buildSmsBody(body: ReqBody, emergencyNumber: string): string { ? `maps.google.com/?q=${body.latitude.toFixed(5)},${body.longitude.toFixed(5)}` : ""; - const core = `RoadSOS EMERGENCY ${sev}${svcs}${loc}${maps}`.trim(); + const payloadText = body.payload?.trim() ? `CTX: ${body.payload.trim()} ` : ""; + + const core = `RoadSOS EMERGENCY ${sev}${svcs}${loc}${payloadText}${maps}`.trim(); return core.slice(0, 1500); } diff --git a/supabase/migrations/20260517000000_sos_broadcasts.sql b/supabase/migrations/20260517000000_sos_broadcasts.sql new file mode 100644 index 0000000..252d458 --- /dev/null +++ b/supabase/migrations/20260517000000_sos_broadcasts.sql @@ -0,0 +1,35 @@ +-- SOS Broadcasts table: stores real-time emergency broadcasts for nearby +-- responders and facilities. Replaces the previous simulated "nearby services" +-- channel that returned fake success after a 3-second delay. +CREATE TABLE IF NOT EXISTS sos_broadcasts ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + incident_id TEXT NOT NULL, + user_id UUID REFERENCES auth.users(id), + latitude DOUBLE PRECISION NOT NULL, + longitude DOUBLE PRECISION NOT NULL, + severity INTEGER NOT NULL CHECK (severity BETWEEN 1 AND 5), + services_needed TEXT NOT NULL DEFAULT 'ambulance', + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'resolved', 'expired')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + resolved_at TIMESTAMPTZ +); + +CREATE INDEX idx_sos_broadcasts_active ON sos_broadcasts (status, created_at DESC) + WHERE status = 'active'; + +CREATE INDEX idx_sos_broadcasts_location ON sos_broadcasts (latitude, longitude) + WHERE status = 'active'; + +ALTER TABLE sos_broadcasts ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Users can insert their own broadcasts" + ON sos_broadcasts FOR INSERT + WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "Anyone can read active broadcasts" + ON sos_broadcasts FOR SELECT + USING (status = 'active'); + +CREATE POLICY "Users can update their own broadcasts" + ON sos_broadcasts FOR UPDATE + USING (auth.uid() = user_id);