diff --git a/README.md b/README.md index 4be17c5..68bae48 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,8 @@ The existing solution — call 108 — fails when the victim is unconscious, whe RoadSOS is an offline-first, life-safety platform that: 1. **Detects crashes automatically** — accelerometer + GPS fusion; fires SOS if the user is unconscious -2. **Triages severity using Gemma 4** — multimodal: analyzes a crash-scene photo + voice description -3. **Dispatches emergency services** — SMS, BLE beacon, Supabase realtime, all in parallel +2. **Triages severity using Gemma 4** — text-first during auto-SOS, with bystander-assisted photo analysis when a scene image is available +3. **Dispatches parallel emergency signals** — SMS, BLE beacon, family link, and on-device incident logging in parallel 4. **Guides bystanders** — voice-assisted first aid in 6 Indian languages 5. **Works with no internet** — Gemma 4 E4B runs on-device for offline triage @@ -38,8 +38,8 @@ RoadSOS is an offline-first, life-safety platform that: Gemma 4 has three capabilities no previous model in its weight class provided: -**1. Multimodal vision (crash scene analysis)** -When SOS triggers, the phone silently captures one frame from the rear camera. Gemma 4 27B analyzes the image alongside the voice description: *fire visible? smoke? trapped occupants? vehicle count? road type?* This changes triage from "someone said it was bad" to "Gemma 4 confirmed fire and two trapped occupants." +**1. Multimodal vision (bystander scene analysis)** +When a bystander can safely frame the scene, RoadSOS can attach a crash photo for Gemma 4 27B to analyze alongside the voice description: *fire visible? smoke? trapped occupants? vehicle count? road type?* The automatic SOS path remains text-first because silent capture is unreliable when the phone is in a pocket, on a seat, or facing away from the crash. **2. Multilingual for India** Gemma 4 understands Hindi, Tamil, Bengali, Marathi, and Telugu natively — not via translation. An emergency description in mixed Hindi-English ("truck ne humari gaadi ko hit kiya, khoon aa raha hai, hospital kahan hai?") produces accurate triage, not garbled output. @@ -78,7 +78,7 @@ The app automatically selects the highest-quality available tier at emergency ti This JSON triggers: - Automated SMS to 108/112 ERSS with GPS coordinates and severity - BLE beacon broadcast for nearby RoadSOS users -- Real-time database record for emergency responders +- Local incident logging plus optional family-circle sharing - Voice-guided first aid in the user's language --- @@ -91,7 +91,7 @@ EmergencyOrchestrator ├── CrashDetectionService │ └── accelerometer spike + GPS speed + stillness check (multi-stage) │ - ├── CameraTriageService ← NEW: captures crash-scene photo for Gemma 4 vision + ├── CameraTriageService ← bystander photo capture for Gemma 4 vision │ ├── AiTriageService — 4-tier Gemma 4 inference stack │ ├── Tier 1: Gemma 4 27B + vision (Supabase Edge Function) @@ -100,7 +100,7 @@ EmergencyOrchestrator │ └── Tier 4: OfflineTriageClassifier (keyword fallback) │ ├── EmergencySmsDispatchService (Twilio, server-side — no key on device) - ├── MeshNetworkService (BLE AES-GCM encrypted beacon) + ├── MeshNetworkService (BLE SOS beacon for nearby RoadSOS phones) └── VoiceAssistantService (TTS + STT, 6 Indian languages) ``` @@ -109,18 +109,35 @@ EmergencyOrchestrator ## Key Features - **Crash auto-detection** — multi-stage accelerometer + GPS fusion; configurable thresholds; false-positive resistant -- **Gemma 4 vision triage** — crash-scene photo analyzed by Gemma 4 27B alongside voice description +- **Gemma 4 vision triage** — bystander-supplied crash-scene photo analyzed by Gemma 4 27B alongside voice description - **4-tier inference** — seamless degradation from cloud to on-device to deterministic - **Server-side SMS** — automated Twilio dispatch; works for unconscious victims; no API key on device -- **BLE encrypted mesh** — AES-GCM beacon so nearby users see an alert even with no server -- **First aid RAG** — 80+ entry SQLite FTS5 corpus; Gemma 4 E4B runs lookup on-device -- **6 Indian languages** — English, Hindi, Bengali, Marathi, Tamil, Telugu; full localization +- **BLE SOS beacon** — app-to-app broadcast so nearby RoadSOS users can detect an alert even with no server +- **First-aid guidance library** — 80+ entry SQLite FTS5 corpus with offline lookup and emergency disclaimer +- **6 Indian languages** — English, Hindi, Bengali, Marathi, Tamil, Telugu across the core emergency surfaces - **Voice SOS** — TTS + STT for hands-busy emergencies - **Offline maps** — PowerSync regional hospital/trauma center data; works offline - **Good Samaritan guidance** — Indian law explained in-app so bystanders know they're protected --- +## Feature Status + +RoadSOS is safer when its limits are explicit. Current status: + +| Feature | Status | Reality check | +|---------|--------|---------------| +| Crash auto-detection | **Partial** | Requires motion sensors, permissions, and usable GPS speed context; not foolproof in tunnels, denied-permission cases, or cold-start GPS loss. | +| Gemma 4 auto-SOS triage | **Real** | Text-first triage runs in the emergency pipeline with cloud -> on-device -> heuristic -> keyword fallback. | +| Gemma 4 vision triage | **Partial** | Works only when a bystander supplies a scene photo; automatic silent camera capture is not used in the auto-SOS path. | +| SMS emergency dispatch | **Partial** | Real when the carrier/backend path is configured and reachable; request acceptance is not the same as confirmed ambulance arrival. | +| BLE SOS beacon | **Partial** | Nearby RoadSOS phones can detect the broadcast, but this is not a substitute for EMS dispatch and is not a multi-hop public mesh. | +| Nearby responder relay | **Planned / not configured** | The app shows this as skipped when no real responder relay is wired. | +| Offline first-aid guidance | **Real** | Uses the bundled SQLite/FTS guidance library with emergency disclaimers; it is guidance, not medical advice. | +| Family circle / tracking | **Partial** | Useful when the user already has a signed-in family circle and connectivity; not guaranteed in every emergency. | + +--- + ## Why Gemma 4 Is Not Optional This is the question that eliminates 90% of hackathon submissions: *"Could you replace Gemma 4 with GPT-4o or any other model?"* @@ -152,7 +169,7 @@ RoadSOS is not a chatbot. It is an emergency response agent that takes real-worl ``` AGENT LOOP (fires within 10 seconds of crash detection): ┌─────────────────────────────────────────────────────────────┐ -│ PERCEIVE → Accelerometer spike + GPS + camera frame │ +│ PERCEIVE → Accelerometer spike + GPS context │ │ TRIAGE → Gemma 4 27B or E4B: structured severity JSON │ │ PLAN → Function calling: which services to dispatch │ │ ACT → dispatch_emergency() + lookup_trauma_center() │ @@ -162,7 +179,7 @@ AGENT LOOP (fires within 10 seconds of crash detection): └─────────────────────────────────────────────────────────────┘ ``` -Gemma 4's function calling is what makes the PLAN → ACT step real. The model doesn't describe what should happen — it calls `dispatch_emergency(severity=5, services=["ambulance","fire_department","rescue"], gps="28.62,77.37", sms="RoadSOS SOS...")`. The Kaggle notebook (Cell 11) shows this live. +Gemma 4's structured output is what makes the PLAN → ACT step real. In the app, the model returns typed JSON (severity, services, first-aid focus), and the Dart dispatch pipeline executes the actual SMS / BLE / family-link actions. The Kaggle notebook (Cell 11) shows the structured triage flow. --- @@ -172,7 +189,7 @@ Gemma 4's function calling is what makes the PLAN → ACT step real. The model d |------------------|--------|-----------------| | **Impact & Vision** | 40% | 170,000 deaths/year. 350M+ target users. MIT licensed for any state EMS. Deployable with zero custom infra. | | **Video Storytelling** | 30% | Full 3-min script in `VIDEO_SCRIPT.md`. Emotional hook → live demo → wow moment → scale. Keyword vs Gemma split-screen. | -| **Technical Depth** | 30% | 4-tier inference routing. Real flutter_gemma LiteRT integration. Function calling agent (Cell 11). BLE AES-GCM mesh. Server-side Twilio SMS. 80-entry RAG corpus. | +| **Technical Depth** | 30% | 4-tier inference routing. Real flutter_gemma LiteRT integration. Structured triage demo (Cell 11). BLE SOS beacon. Server-side Twilio SMS. 80-entry RAG corpus. | **Track alignment:** - **Safety & Trust** — primary track; crash detection + dispatch + bystander guidance is pure safety infrastructure @@ -270,13 +287,13 @@ RoadSOS exists because the difference between life and death on an Indian highwa These are the five questions experienced hackathon judges ask every safety-AI project. Answered here so they're in the repo, not just in the video. **"Is this just GPT-4 with a safety prompt?"** -No — GPT-4 doesn't run on a phone with no internet. Gemma 4 E4B does, via MediaPipe LiteRT. The offline tier is the entire point: 60% of fatal crashes in India happen where GPT-4 has no signal. See Cell 11 for function calling proof. +No — GPT-4 doesn't run on a phone with no internet. Gemma 4 E4B does, via MediaPipe LiteRT. The offline tier is the entire point: 60% of fatal crashes in India happen where GPT-4 has no signal. See Cell 11 for structured triage proof. **"Does the offline mode actually work?"** `gemma_local_service.dart` calls `FlutterGemmaPlugin.instance.init()` with the local model path. `gemma_model_manager.dart` handles the 2.4 GB download with resume support. Switch the phone to airplane mode — Tiers 3 and 4 always work, Tier 2 works once the model is downloaded. **"Is the SMS dispatch real?"** -The Twilio relay is a Supabase Edge Function (`supabase/functions/triage-gemini/`). The API key lives server-side. The app sends no credentials. An unconscious victim's phone fires the SMS because the app detected the crash — not because they pressed anything. +The Twilio relay is a Supabase Edge Function (`supabase/functions/sms-dispatch/`). The API key lives server-side. The app sends no credentials. An unconscious victim's phone can request SMS dispatch because the app detected the crash — not because they pressed anything. **"What about false positives — won't the accelerometer trigger while off-road?"** Multi-stage detection: accelerometer spike AND sudden GPS velocity drop AND absence of deliberate phone movement afterward. Three independent signals must agree. False positive rate in testing: < 1 per 200 hours of driving. diff --git a/lib/l10n/app_bn.arb b/lib/l10n/app_bn.arb index 02253cb..873d86d 100644 --- a/lib/l10n/app_bn.arb +++ b/lib/l10n/app_bn.arb @@ -8,12 +8,17 @@ "sosButtonSub": "জরুরি শুরু করতে চাপুন", "cancelSos": "বাতিল", "orchestratorAcquiringLocation": "অবস্থান নেওয়া হচ্ছে…", - "orchestratorAiBrief": "ক্লাউড AI পরিস্থিতি যাচাই করছে…", + "orchestratorAiBrief": "AI ট্রায়েজ পরিস্থিতি যাচাই করছে…", "orchestratorDispatching": "সব চ্যানেলে পাঠানো হচ্ছে…", "orchestratorSosLive": "SOS সক্রিয়", "triageResultTitle": "AI ট্রায়েজ ফল", "triageDegradedTitle": "AI (অফলাইন)", "firstAidGuidance": "প্রাথমিক চিকিৎসা", + "firstAidScreenTitle": "প্রাথমিক চিকিৎসা গাইড", + "firstAidSearchHint": "আঘাত বা উপসর্গ লিখুন…", + "firstAidLookupTitle": "জরুরি প্রাথমিক চিকিৎসা খোঁজ", + "firstAidLookupSubtitle": "অফলাইন নির্দেশিকা লাইব্রেরি খুঁজতে আঘাত লিখুন।\nএটি শুধু দিকনির্দেশনা — প্রকৃত চিকিৎসা সহায়তার জন্য 108/112 নম্বরে কল করুন।", + "firstAidLoadError": "এই ডিভাইসে প্রাথমিক চিকিৎসার নির্দেশনা লোড করা যায়নি।", "settingsLanguage": "ভাষা", "settingsLanguageSubtitle": "ইন্টারফেস ও ভয়েস", "incidentVoiceHint": "যা দেখছেন বলুন…", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index c3c5038..ffa01e9 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -22,7 +22,7 @@ "orchestratorLocationUnavailable": "Location unavailable — enable location services or move to open sky.", "orchestratorManualActionRequired": "Manual action required — if automated dispatch fails, dial your emergency number now.", "orchestratorSmsNoGpsPayload": "SOS (no GPS). Please call emergency services now. RoadSOS could not acquire location.", - "orchestratorAiBrief": "Cloud AI is assessing the situation…", + "orchestratorAiBrief": "AI triage is assessing the situation…", "orchestratorTriageDone": "Triage complete — severity {level}", "@orchestratorTriageDone": { "placeholders": { @@ -58,6 +58,11 @@ "severityUnknown": "UNKNOWN", "dispatchedServices": "DISPATCHED SERVICES", "firstAidGuidance": "FIRST AID GUIDANCE", + "firstAidScreenTitle": "First aid guide", + "firstAidSearchHint": "Describe injury or symptom…", + "firstAidLookupTitle": "Emergency first-aid lookup", + "firstAidLookupSubtitle": "Type an injury to search the offline guidance library.\nFor guidance only — call 108/112 for real medical help.", + "firstAidLoadError": "Could not load first-aid guidance on this device.", "noAiBadge": "OFFLINE", "settingsTitle": "SETTINGS", "sectionConnectivity": "CONNECTIVITY", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index a4c1e85..80118ad 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -12,7 +12,7 @@ "orchestratorSelfSosStarted": "सेल्फ SOS शुरू", "orchestratorAcquiringLocation": "स्थान प्राप्त कर रहे हैं…", "orchestratorLocationSecured": "स्थान: {lat}, {lng}", - "orchestratorAiBrief": "क्लाउड AI स्थिति जांच रहा है…", + "orchestratorAiBrief": "AI ट्राइएज स्थिति जांच रहा है…", "orchestratorTriageDone": "ट्राइएज पूर्ण — गंभीरता {level}", "orchestratorDispatching": "सभी चैनलों पर अलर्ट भेज रहे हैं…", "orchestratorSosLive": "SOS सक्रिय — सभी चैनल चालू", @@ -37,6 +37,11 @@ "severityUnknown": "अज्ञात", "dispatchedServices": "भेजी गई सेवाएँ", "firstAidGuidance": "प्राथमिक उपचार मार्गदर्शन", + "firstAidScreenTitle": "प्राथमिक उपचार गाइड", + "firstAidSearchHint": "चोट या लक्षण लिखें…", + "firstAidLookupTitle": "आपातकालीन प्राथमिक उपचार खोज", + "firstAidLookupSubtitle": "ऑफ़लाइन मार्गदर्शन लाइब्रेरी खोजने के लिए चोट लिखें।\nयह केवल मार्गदर्शन है — वास्तविक चिकित्सा मदद के लिए 108/112 पर कॉल करें।", + "firstAidLoadError": "इस डिवाइस पर प्राथमिक उपचार मार्गदर्शन लोड नहीं हो सका।", "noAiBadge": "ऑफ़लाइन", "settingsTitle": "सेटिंग्स", "sectionConnectivity": "कनेक्टिविटी", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 6ee44aa..0378c17 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -205,7 +205,7 @@ abstract class AppLocalizations { /// No description provided for @orchestratorAiBrief. /// /// In en, this message translates to: - /// **'Cloud AI is assessing the situation…'** + /// **'AI triage is assessing the situation…'** String get orchestratorAiBrief; /// No description provided for @orchestratorTriageDone. @@ -352,6 +352,36 @@ abstract class AppLocalizations { /// **'FIRST AID GUIDANCE'** String get firstAidGuidance; + /// No description provided for @firstAidScreenTitle. + /// + /// In en, this message translates to: + /// **'First aid guide'** + String get firstAidScreenTitle; + + /// No description provided for @firstAidSearchHint. + /// + /// In en, this message translates to: + /// **'Describe injury or symptom…'** + String get firstAidSearchHint; + + /// No description provided for @firstAidLookupTitle. + /// + /// In en, this message translates to: + /// **'Emergency first-aid lookup'** + String get firstAidLookupTitle; + + /// No description provided for @firstAidLookupSubtitle. + /// + /// In en, this message translates to: + /// **'Type an injury to search the offline guidance library.\nFor guidance only — call 108/112 for real medical help.'** + String get firstAidLookupSubtitle; + + /// No description provided for @firstAidLoadError. + /// + /// In en, this message translates to: + /// **'Could not load first-aid guidance on this device.'** + String get firstAidLoadError; + /// No description provided for @noAiBadge. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_bn.dart b/lib/l10n/app_localizations_bn.dart index b3bc05d..a2c9a2c 100644 --- a/lib/l10n/app_localizations_bn.dart +++ b/lib/l10n/app_localizations_bn.dart @@ -63,7 +63,7 @@ class AppLocalizationsBn extends AppLocalizations { 'SOS (no GPS). Please call emergency services now. RoadSOS could not acquire location.'; @override - String get orchestratorAiBrief => 'ক্লাউড AI পরিস্থিতি যাচাই করছে…'; + String get orchestratorAiBrief => 'AI ট্রায়েজ পরিস্থিতি যাচাই করছে…'; @override String orchestratorTriageDone(int level) { @@ -141,6 +141,23 @@ class AppLocalizationsBn extends AppLocalizations { @override String get firstAidGuidance => 'প্রাথমিক চিকিৎসা'; + @override + String get firstAidScreenTitle => 'প্রাথমিক চিকিৎসা গাইড'; + + @override + String get firstAidSearchHint => 'আঘাত বা উপসর্গ লিখুন…'; + + @override + String get firstAidLookupTitle => 'জরুরি প্রাথমিক চিকিৎসা খোঁজ'; + + @override + String get firstAidLookupSubtitle => + 'অফলাইন নির্দেশিকা লাইব্রেরি খুঁজতে আঘাত লিখুন।\nএটি শুধু দিকনির্দেশনা — প্রকৃত চিকিৎসা সহায়তার জন্য 108/112 নম্বরে কল করুন।'; + + @override + String get firstAidLoadError => + 'এই ডিভাইসে প্রাথমিক চিকিৎসার নির্দেশনা লোড করা যায়নি।'; + @override String get noAiBadge => 'OFFLINE'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index bbe2f47..3a2450c 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -63,7 +63,7 @@ class AppLocalizationsEn extends AppLocalizations { 'SOS (no GPS). Please call emergency services now. RoadSOS could not acquire location.'; @override - String get orchestratorAiBrief => 'Cloud AI is assessing the situation…'; + String get orchestratorAiBrief => 'AI triage is assessing the situation…'; @override String orchestratorTriageDone(int level) { @@ -142,6 +142,23 @@ class AppLocalizationsEn extends AppLocalizations { @override String get firstAidGuidance => 'FIRST AID GUIDANCE'; + @override + String get firstAidScreenTitle => 'First aid guide'; + + @override + String get firstAidSearchHint => 'Describe injury or symptom…'; + + @override + String get firstAidLookupTitle => 'Emergency first-aid lookup'; + + @override + String get firstAidLookupSubtitle => + 'Type an injury to search the offline guidance library.\nFor guidance only — call 108/112 for real medical help.'; + + @override + String get firstAidLoadError => + 'Could not load first-aid guidance on this device.'; + @override String get noAiBadge => 'OFFLINE'; diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index e2c5031..b2349bc 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -63,7 +63,7 @@ class AppLocalizationsHi extends AppLocalizations { 'SOS (no GPS). Please call emergency services now. RoadSOS could not acquire location.'; @override - String get orchestratorAiBrief => 'क्लाउड AI स्थिति जांच रहा है…'; + String get orchestratorAiBrief => 'AI ट्राइएज स्थिति जांच रहा है…'; @override String orchestratorTriageDone(int level) { @@ -141,6 +141,23 @@ class AppLocalizationsHi extends AppLocalizations { @override String get firstAidGuidance => 'प्राथमिक उपचार मार्गदर्शन'; + @override + String get firstAidScreenTitle => 'प्राथमिक उपचार गाइड'; + + @override + String get firstAidSearchHint => 'चोट या लक्षण लिखें…'; + + @override + String get firstAidLookupTitle => 'आपातकालीन प्राथमिक उपचार खोज'; + + @override + String get firstAidLookupSubtitle => + 'ऑफ़लाइन मार्गदर्शन लाइब्रेरी खोजने के लिए चोट लिखें।\nयह केवल मार्गदर्शन है — वास्तविक चिकित्सा मदद के लिए 108/112 पर कॉल करें।'; + + @override + String get firstAidLoadError => + 'इस डिवाइस पर प्राथमिक उपचार मार्गदर्शन लोड नहीं हो सका।'; + @override String get noAiBadge => 'ऑफ़लाइन'; diff --git a/lib/l10n/app_localizations_mr.dart b/lib/l10n/app_localizations_mr.dart index 4bb048a..f21741c 100644 --- a/lib/l10n/app_localizations_mr.dart +++ b/lib/l10n/app_localizations_mr.dart @@ -63,7 +63,7 @@ class AppLocalizationsMr extends AppLocalizations { 'SOS (no GPS). Please call emergency services now. RoadSOS could not acquire location.'; @override - String get orchestratorAiBrief => 'क्लाउड AI परिस्थिती तपासत आहे…'; + String get orchestratorAiBrief => 'AI ट्रायज परिस्थिती तपासत आहे…'; @override String orchestratorTriageDone(int level) { @@ -141,6 +141,23 @@ class AppLocalizationsMr extends AppLocalizations { @override String get firstAidGuidance => 'प्रथमोपचार'; + @override + String get firstAidScreenTitle => 'प्राथमिक उपचार मार्गदर्शक'; + + @override + String get firstAidSearchHint => 'इजा किंवा लक्षण लिहा…'; + + @override + String get firstAidLookupTitle => 'आपत्कालीन प्राथमिक उपचार शोध'; + + @override + String get firstAidLookupSubtitle => + 'ऑफलाइन मार्गदर्शन लायब्ररी शोधण्यासाठी इजा लिहा.\nहे फक्त मार्गदर्शन आहे — खऱ्या वैद्यकीय मदतीसाठी 108/112 वर कॉल करा.'; + + @override + String get firstAidLoadError => + 'या डिव्हाइसवर प्राथमिक उपचार मार्गदर्शन लोड करता आले नाही.'; + @override String get noAiBadge => 'OFFLINE'; diff --git a/lib/l10n/app_localizations_ta.dart b/lib/l10n/app_localizations_ta.dart index 292baa9..fa3407e 100644 --- a/lib/l10n/app_localizations_ta.dart +++ b/lib/l10n/app_localizations_ta.dart @@ -63,7 +63,7 @@ class AppLocalizationsTa extends AppLocalizations { 'SOS (no GPS). Please call emergency services now. RoadSOS could not acquire location.'; @override - String get orchestratorAiBrief => 'மேகக் கணினி நிலை மதிப்பீடு…'; + String get orchestratorAiBrief => 'AI ட்ரையாஜ் நிலை மதிப்பீடு…'; @override String orchestratorTriageDone(int level) { @@ -141,6 +141,23 @@ class AppLocalizationsTa extends AppLocalizations { @override String get firstAidGuidance => 'முதலுதவி'; + @override + String get firstAidScreenTitle => 'முதலுதவி வழிகாட்டி'; + + @override + String get firstAidSearchHint => 'காயம் அல்லது அறிகுறியை எழுதுங்கள்…'; + + @override + String get firstAidLookupTitle => 'அவசர முதலுதவி தேடல்'; + + @override + String get firstAidLookupSubtitle => + 'ஆஃப்லைன் வழிகாட்டி நூலகத்தில் தேட காயத்தை உள்ளிடுங்கள்.\nஇது வழிகாட்டலுக்கே — உண்மையான மருத்துவ உதவிக்கு 108/112 ஐ அழைக்கவும்.'; + + @override + String get firstAidLoadError => + 'இந்த சாதனத்தில் முதலுதவி வழிகாட்டலை ஏற்ற முடியவில்லை.'; + @override String get noAiBadge => 'OFFLINE'; diff --git a/lib/l10n/app_localizations_te.dart b/lib/l10n/app_localizations_te.dart index b8abff6..b4a3cc3 100644 --- a/lib/l10n/app_localizations_te.dart +++ b/lib/l10n/app_localizations_te.dart @@ -63,7 +63,7 @@ class AppLocalizationsTe extends AppLocalizations { 'SOS (no GPS). Please call emergency services now. RoadSOS could not acquire location.'; @override - String get orchestratorAiBrief => 'క్లౌడ్ AI పరిస్థితి అంచనా…'; + String get orchestratorAiBrief => 'AI ట్రయేజ్ పరిస్థితిని అంచనా వేస్తోంది…'; @override String orchestratorTriageDone(int level) { @@ -141,6 +141,23 @@ class AppLocalizationsTe extends AppLocalizations { @override String get firstAidGuidance => 'మొదటి చికిత్స'; + @override + String get firstAidScreenTitle => 'ప్రథమ చికిత్స మార్గదర్శిని'; + + @override + String get firstAidSearchHint => 'గాయం లేదా లక్షణాన్ని వ్రాయండి…'; + + @override + String get firstAidLookupTitle => 'అత్యవసర ప్రథమ చికిత్స శోధన'; + + @override + String get firstAidLookupSubtitle => + 'ఆఫ్‌లైన్ మార్గదర్శక గ్రంథాలయంలో వెతకడానికి గాయాన్ని నమోదు చేయండి.\nఇది మార్గదర్శకమే — నిజమైన వైద్య సహాయం కోసం 108/112 కు కాల్ చేయండి.'; + + @override + String get firstAidLoadError => + 'ఈ పరికరంలో ప్రథమ చికిత్స మార్గదర్శకాన్ని లోడ్ చేయలేకపోయాం.'; + @override String get noAiBadge => 'OFFLINE'; diff --git a/lib/l10n/app_mr.arb b/lib/l10n/app_mr.arb index 7284104..b511822 100644 --- a/lib/l10n/app_mr.arb +++ b/lib/l10n/app_mr.arb @@ -8,12 +8,17 @@ "sosButtonSub": "आणीबाणी सुरू करण्यासाठी दाबा", "cancelSos": "रद्द", "orchestratorAcquiringLocation": "स्थान मिळवत आहे…", - "orchestratorAiBrief": "क्लाउड AI परिस्थिती तपासत आहे…", + "orchestratorAiBrief": "AI ट्रायज परिस्थिती तपासत आहे…", "orchestratorDispatching": "सर्व चॅनेलवर पाठवत आहे…", "orchestratorSosLive": "SOS सक्रिय", "triageResultTitle": "AI ट्रायज परिणाम", "triageDegradedTitle": "AI (ऑफलाइन)", "firstAidGuidance": "प्रथमोपचार", + "firstAidScreenTitle": "प्राथमिक उपचार मार्गदर्शक", + "firstAidSearchHint": "इजा किंवा लक्षण लिहा…", + "firstAidLookupTitle": "आपत्कालीन प्राथमिक उपचार शोध", + "firstAidLookupSubtitle": "ऑफलाइन मार्गदर्शन लायब्ररी शोधण्यासाठी इजा लिहा.\nहे फक्त मार्गदर्शन आहे — खऱ्या वैद्यकीय मदतीसाठी 108/112 वर कॉल करा.", + "firstAidLoadError": "या डिव्हाइसवर प्राथमिक उपचार मार्गदर्शन लोड करता आले नाही.", "settingsLanguage": "भाषा", "settingsLanguageSubtitle": "इंटरफेस आणि आवाज", "incidentVoiceHint": "जे दिसते ते सांगा…", diff --git a/lib/l10n/app_ta.arb b/lib/l10n/app_ta.arb index 315b201..2630c57 100644 --- a/lib/l10n/app_ta.arb +++ b/lib/l10n/app_ta.arb @@ -8,12 +8,17 @@ "sosButtonSub": "அவசரத்தைத் தொடங்க அழுத்தவும்", "cancelSos": "ரத்து", "orchestratorAcquiringLocation": "இடம் கிடைக்கிறது…", - "orchestratorAiBrief": "மேகக் கணினி நிலை மதிப்பீடு…", + "orchestratorAiBrief": "AI ட்ரையாஜ் நிலை மதிப்பீடு…", "orchestratorDispatching": "அனைத்து சேனல்களிலும் அனுப்புகிறது…", "orchestratorSosLive": "SOS செயலில்", "triageResultTitle": "AI மதிப்பீடு", "triageDegradedTitle": "AI (இணைப்பில்லை)", "firstAidGuidance": "முதலுதவி", + "firstAidScreenTitle": "முதலுதவி வழிகாட்டி", + "firstAidSearchHint": "காயம் அல்லது அறிகுறியை எழுதுங்கள்…", + "firstAidLookupTitle": "அவசர முதலுதவி தேடல்", + "firstAidLookupSubtitle": "ஆஃப்லைன் வழிகாட்டி நூலகத்தில் தேட காயத்தை உள்ளிடுங்கள்.\nஇது வழிகாட்டலுக்கே — உண்மையான மருத்துவ உதவிக்கு 108/112 ஐ அழைக்கவும்.", + "firstAidLoadError": "இந்த சாதனத்தில் முதலுதவி வழிகாட்டலை ஏற்ற முடியவில்லை.", "settingsLanguage": "மொழி", "settingsLanguageSubtitle": "இடைமுகம் மற்றும் குரல்", "incidentVoiceHint": "என்ன காண்கிறீர்கள் என்று சொல்லுங்கள்…", diff --git a/lib/l10n/app_te.arb b/lib/l10n/app_te.arb index 9b71bd9..67abb71 100644 --- a/lib/l10n/app_te.arb +++ b/lib/l10n/app_te.arb @@ -8,12 +8,17 @@ "sosButtonSub": "అత్యవసరాన్ని ప్రారంభించడానికి నొక్కండి", "cancelSos": "రద్దు", "orchestratorAcquiringLocation": "స్థానం పొందుతున్నాం…", - "orchestratorAiBrief": "క్లౌడ్ AI పరిస్థితి అంచనా…", + "orchestratorAiBrief": "AI ట్రయేజ్ పరిస్థితిని అంచనా వేస్తోంది…", "orchestratorDispatching": "అన్ని చానల్‌లకు పంపుతున్నాం…", "orchestratorSosLive": "SOS సక్రియం", "triageResultTitle": "AI ట్రయేజ్ ఫలితం", "triageDegradedTitle": "AI (ఆఫ్‌లైన్)", "firstAidGuidance": "మొదటి చికిత్స", + "firstAidScreenTitle": "ప్రథమ చికిత్స మార్గదర్శిని", + "firstAidSearchHint": "గాయం లేదా లక్షణాన్ని వ్రాయండి…", + "firstAidLookupTitle": "అత్యవసర ప్రథమ చికిత్స శోధన", + "firstAidLookupSubtitle": "ఆఫ్‌లైన్ మార్గదర్శక గ్రంథాలయంలో వెతకడానికి గాయాన్ని నమోదు చేయండి.\nఇది మార్గదర్శకమే — నిజమైన వైద్య సహాయం కోసం 108/112 కు కాల్ చేయండి.", + "firstAidLoadError": "ఈ పరికరంలో ప్రథమ చికిత్స మార్గదర్శకాన్ని లోడ్ చేయలేకపోయాం.", "settingsLanguage": "భాష", "settingsLanguageSubtitle": "UI మరియు వాయిస్", "incidentVoiceHint": "మీరు చూసేది చెప్పండి…", diff --git a/lib/services/ai_triage_service.dart b/lib/services/ai_triage_service.dart index f3b3891..486bfef 100644 --- a/lib/services/ai_triage_service.dart +++ b/lib/services/ai_triage_service.dart @@ -90,23 +90,23 @@ class TriageResult { }); Map toJson() => { - 'function_call': functionCall, - 'arguments': { - 'location': location, - 'severity_level': severityLevel, - 'required_services': requiredServices, - 'first_aid_rag_query': firstAidQuery, - 'compressed_payload': compressedPayload, - }, - 'thinking_trace': thinkingTrace, - 'degraded_mode': isDegradedMode, - 'source': source.name, - 'vision_used': visionUsed, - 'confidence': confidence, - 'validation_flags': validationFlags, - 'was_overridden': wasOverridden, - 'validation_notes': validationNotes, - }; + 'function_call': functionCall, + 'arguments': { + 'location': location, + 'severity_level': severityLevel, + 'required_services': requiredServices, + 'first_aid_rag_query': firstAidQuery, + 'compressed_payload': compressedPayload, + }, + 'thinking_trace': thinkingTrace, + 'degraded_mode': isDegradedMode, + 'source': source.name, + 'vision_used': visionUsed, + 'confidence': confidence, + 'validation_flags': validationFlags, + 'was_overridden': wasOverridden, + 'validation_notes': validationNotes, + }; String get sourceLabel { switch (source) { @@ -211,15 +211,19 @@ class AiTriageService { const CapturedScenePhoto? scenePhoto = null; - final skipCloud = _connectivityAwareTriage && + final skipCloud = + _connectivityAwareTriage && _connectivity.currentQuality == NetworkQuality.none; if (skipCloud) { - appLog.d('[Triage] Connectivity=none — skipping Tier 1 cloud (saves 5s timeout)'); + appLog.d( + '[Triage] Connectivity=none — skipping Tier 1 cloud (saves 3s timeout)', + ); } else { + // Hard-fallback budget from the rulebook: move to the next tier within 3s. final cloudTimeout = _connectivity.currentQuality == NetworkQuality.wifi - ? const Duration(seconds: 8) - : const Duration(seconds: 5); + ? const Duration(seconds: 3) + : const Duration(seconds: 3); try { final cloud = await _callGemma4Cloud( transcript: audioTranscript, @@ -231,8 +235,11 @@ class AiTriageService { appLog.i('[Triage] Tier 1 — Gemma 4 27B cloud ✓ (text-only auto-SOS)'); return cloud; } catch (e, st) { - appLog.d('[Triage] Tier 1 unavailable, trying Tier 2 on-device', - error: e, stackTrace: st); + appLog.d( + '[Triage] Tier 1 unavailable, trying Tier 2 on-device', + error: e, + stackTrace: st, + ); } } @@ -243,7 +250,7 @@ class AiTriageService { location: locationString, severityHint: accelerometerSeverityHint, languageCode: languageCode, - ).timeout(const Duration(seconds: 8)); + ).timeout(const Duration(seconds: 3)); if (onDevice != null) { appLog.i('[Triage] Tier 2 — Gemma 4 E4B on-device ✓'); return onDevice; @@ -253,8 +260,23 @@ class AiTriageService { } } - appLog.d('[Triage] Tier 3 — local heuristic model'); - return _buildTier3Triage( + try { + appLog.d('[Triage] Tier 3 — local heuristic model'); + return await _buildTier3Triage( + transcript: audioTranscript, + location: locationString, + severityHint: accelerometerSeverityHint, + languageCode: languageCode, + ); + } catch (e, st) { + appLog.w( + '[Triage] Tier 3 failed — using Tier 4 keyword classifier', + error: e, + stackTrace: st, + ); + } + + return _buildClassifierTriage( transcript: audioTranscript, location: locationString, severityHint: accelerometerSeverityHint, @@ -272,8 +294,8 @@ class AiTriageService { final locationString = '${location.latitude},${location.longitude}'; final ctx = transcript.trim().isEmpty ? (isBystander - ? 'Bystander reporting roadside emergency' - : 'Emergency SOS triggered') + ? 'Bystander reporting roadside emergency' + : 'Emergency SOS triggered') : transcript; return triageEmergency( @@ -350,7 +372,8 @@ class AiTriageService { if (data is! Map) throw Exception('Edge triage returned non-object'); final payload = Map.from(data); - final severity = (payload['severity_level'] as num?)?.toInt().clamp(1, 5) ?? 4; + final severity = + (payload['severity_level'] as num?)?.toInt().clamp(1, 5) ?? 4; final rawServices = payload['required_services']; final services = {'ambulance'}; if (rawServices is List) { @@ -366,8 +389,9 @@ class AiTriageService { final fallbackQuery = _classifier .classify(transcript: transcript, severityHint: severityHint) .firstAidQuery; - final aidQuery = - (fromCloud != null && fromCloud.isNotEmpty) ? fromCloud : fallbackQuery; + final aidQuery = (fromCloud != null && fromCloud.isNotEmpty) + ? fromCloud + : fallbackQuery; final thinking = payload['thinking_summary'] as String?; final modelUsed = payload['_model'] as String?; final visionUsed = payload['_vision_used'] == true; @@ -380,12 +404,18 @@ class AiTriageService { severityLevel: severity, requiredServices: services.toList(), firstAidQuery: verifiedAdvice, - compressedPayload: _buildCompressedPayload(location, severity, services.toList()), + compressedPayload: _buildCompressedPayload( + location, + severity, + services.toList(), + ), thinkingTrace: thinking != null ? '[${modelUsed ?? "Gemma 4 27B"}${visionUsed ? " + vision" : ""}] $thinking' : null, isDegradedMode: false, - source: visionUsed ? TriageSource.gemma4CloudVision : TriageSource.gemma4Cloud, + source: visionUsed + ? TriageSource.gemma4CloudVision + : TriageSource.gemma4Cloud, visionUsed: visionUsed, ); } @@ -406,7 +436,8 @@ class AiTriageService { ); if (result == null) return null; - final severity = (result['severity_level'] as num?)?.toInt().clamp(1, 5) ?? 3; + final severity = + (result['severity_level'] as num?)?.toInt().clamp(1, 5) ?? 3; final rawServices = result['required_services']; final services = {'ambulance'}; if (rawServices is List) { @@ -416,7 +447,8 @@ class AiTriageService { } } } - final aidQuery = (result['first_aid_focus'] as String?)?.trim() ?? + final aidQuery = + (result['first_aid_focus'] as String?)?.trim() ?? _classifier .classify(transcript: transcript, severityHint: severityHint) .firstAidQuery; @@ -430,7 +462,11 @@ class AiTriageService { severityLevel: severity, requiredServices: services.toList(), firstAidQuery: verifiedAdvice, - compressedPayload: _buildCompressedPayload(location, severity, services.toList()), + compressedPayload: _buildCompressedPayload( + location, + severity, + services.toList(), + ), thinkingTrace: '[Gemma 4 E4B on-device] ${thinking ?? ""}', isDegradedMode: true, source: TriageSource.gemma4OnDevice, @@ -445,7 +481,10 @@ class AiTriageService { required int severityHint, required String languageCode, }) async { - final c = _tier3.classify(transcript: transcript, severityHint: severityHint); + final c = _tier3.classify( + transcript: transcript, + severityHint: severityHint, + ); final verified = await FirstAidStore.getVerifiedAdvice(c.firstAidQuery); return TriageResult( functionCall: 'trigger_sos', @@ -453,7 +492,11 @@ class AiTriageService { severityLevel: c.severityLevel.clamp(1, 5), requiredServices: c.requiredServices, firstAidQuery: verified, - compressedPayload: _buildCompressedPayload(location, c.severityLevel, c.requiredServices), + compressedPayload: _buildCompressedPayload( + location, + c.severityLevel, + c.requiredServices, + ), thinkingTrace: languageCode == 'hi' ? 'स्थानीय भार-विश्लेषण मॉडल (Gemma 4 E4B उपलब्ध नहीं)।' : 'Local weighted heuristic (Gemma 4 E4B unavailable or loading).', @@ -470,7 +513,10 @@ class AiTriageService { required int severityHint, required String languageCode, }) async { - final c = _classifier.classify(transcript: transcript, severityHint: severityHint); + final c = _classifier.classify( + transcript: transcript, + severityHint: severityHint, + ); final severity = c.severityLevel; final services = c.requiredServices; final verified = await FirstAidStore.getVerifiedAdvice(c.firstAidQuery); @@ -491,8 +537,13 @@ class AiTriageService { } static const Set _allowedServices = { - 'ambulance', 'police', 'fire_department', 'rescue', - 'towing', 'puncture_shop', 'showroom', + 'ambulance', + 'police', + 'fire_department', + 'rescue', + 'towing', + 'puncture_shop', + 'showroom', }; String _buildCompressedPayload( @@ -500,18 +551,28 @@ class AiTriageService { int severity, List services, ) { - final svcCodes = services.map((s) { - switch (s) { - case 'ambulance': return 'AMB'; - case 'police': return 'POL'; - case 'fire_department': return 'FIR'; - case 'rescue': return 'RES'; - case 'towing': return 'TOW'; - case 'puncture_shop': return 'PUN'; - case 'showroom': return 'SHR'; - default: return 'UNK'; - } - }).join(','); + final svcCodes = services + .map((s) { + switch (s) { + case 'ambulance': + return 'AMB'; + case 'police': + return 'POL'; + case 'fire_department': + return 'FIR'; + case 'rescue': + return 'RES'; + case 'towing': + return 'TOW'; + case 'puncture_shop': + return 'PUN'; + case 'showroom': + return 'SHR'; + default: + return 'UNK'; + } + }) + .join(','); final loc = location.replaceAll(' ', '_'); final clipped = loc.length <= 30 ? loc : loc.substring(0, 30); diff --git a/lib/services/crash_detection_service.dart b/lib/services/crash_detection_service.dart index 14cae24..7c1709e 100644 --- a/lib/services/crash_detection_service.dart +++ b/lib/services/crash_detection_service.dart @@ -50,8 +50,7 @@ class CrashDetectionService { final Ref _ref; - GyroscopeFusionService get _gyro => - _ref.read(gyroscopeFusionServiceProvider); + GyroscopeFusionService get _gyro => _ref.read(gyroscopeFusionServiceProvider); BluetoothVehicleMonitor get _btMonitor => _ref.read(bluetoothVehicleMonitorProvider); @@ -70,9 +69,9 @@ class CrashDetectionService { stopMonitoring(); // Gyroscope and BT monitor lifecycle managed by their providers. _startGpsSpeed(); - _accelSub = SensorsPlatform.instance - .userAccelerometerEventStream() - .listen(_onAccelerometer); + _accelSub = SensorsPlatform.instance.userAccelerometerEventStream().listen( + _onAccelerometer, + ); } Future _startGpsSpeed() async { @@ -103,10 +102,7 @@ class CrashDetectionService { accuracy: LocationAccuracy.bestForNavigation, distanceFilter: 0, ), - ).listen( - _onPosition, - onError: (Object _) => _gpsSpeedUsable = false, - ); + ).listen(_onPosition, onError: (Object _) => _gpsSpeedUsable = false); } catch (_) { _gpsSpeedUsable = false; } @@ -198,9 +194,10 @@ class CrashDetectionService { } if (minAfter.isInfinite) minAfter = _speedHistory.last.kmh; - final approach = maxBefore >= CrashTuning.minApproachSpeedKmh; - final halted = minAfter <= CrashTuning.stoppedSpeedKmh; - final sharpDrop = (maxBefore - minAfter) >= CrashTuning.suddenDecelDeltaKmh; + final approach = maxBefore >= CrashTuning.minApproachSpeedKmh; + final halted = minAfter <= CrashTuning.stoppedSpeedKmh; + final sharpDrop = + (maxBefore - minAfter) >= CrashTuning.suddenDecelDeltaKmh; if (!approach || !(halted || sharpDrop)) { appLog.d( @@ -230,18 +227,16 @@ class CrashDetectionService { // ── Gate 5: Multi-signal confidence scoring ──────────────────────── final confidence = CrashConfidenceEngine.score( CrashSignals( - accelPeakMs2: peakMs2, - gyroPeakRadPerSec: gyroPeakRadPerSec, - speedBeforeKmh: maxBefore, - speedDropKmh: maxBefore - minAfter, + accelPeakMs2: peakMs2, + gyroPeakRadPerSec: gyroPeakRadPerSec, + speedBeforeKmh: maxBefore, + speedDropKmh: maxBefore - minAfter, bluetoothVehicleDisconnect: _btMonitor.recentDisconnect, - postImpactDeviceStill: still, + postImpactDeviceStill: still, ), ); - appLog.w( - 'CRASH CONFIRMED — $confidence', - ); + appLog.w('CRASH CONFIRMED — $confidence'); // LOW confidence after 4 gates is theoretically impossible but guarded. if (confidence.tier == CrashConfidenceTier.low) { @@ -266,17 +261,54 @@ class CrashDetectionService { 'confidence=${confidence.score.toStringAsFixed(3)} [${confidence.tierLabel}]', ); - _ref.read(emergencyOrchestratorProvider.notifier).triggerSOS(); + final crashSeverityHint = _severityHintFromSignals( + confidence: confidence, + peakMs2: peakMs2, + gyroPeakRadPerSec: gyroPeakRadPerSec, + speedDropKmh: maxBefore - minAfter, + ); + appLog.d( + 'Crash severity hint=$crashSeverityHint ' + '(confidence=${confidence.score.toStringAsFixed(3)} ' + 'peak=${peakMs2.toStringAsFixed(1)} drop=${(maxBefore - minAfter).toStringAsFixed(1)} ' + 'gyro=${gyroPeakRadPerSec.toStringAsFixed(2)})', + ); + + _ref + .read(emergencyOrchestratorProvider.notifier) + .triggerSOS(crashSeverityHint: crashSeverityHint); } finally { _evaluationInFlight = false; } } + int _severityHintFromSignals({ + required CrashConfidenceResult confidence, + required double peakMs2, + required double gyroPeakRadPerSec, + required double speedDropKmh, + }) { + if (confidence.tier == CrashConfidenceTier.high && + (confidence.score >= 0.82 || + peakMs2 >= 70 || + speedDropKmh >= 65 || + gyroPeakRadPerSec >= 4.5)) { + return 5; + } + if (confidence.tier == CrashConfidenceTier.high || + peakMs2 >= 55 || + speedDropKmh >= 40 || + gyroPeakRadPerSec >= 3.5) { + return 4; + } + return 3; + } + Future _measureStillness() async { final magnitudes = []; - final sub = SensorsPlatform.instance - .userAccelerometerEventStream() - .listen((e) => magnitudes.add(sqrt(e.x * e.x + e.y * e.y + e.z * e.z))); + final sub = SensorsPlatform.instance.userAccelerometerEventStream().listen( + (e) => magnitudes.add(sqrt(e.x * e.x + e.y * e.y + e.z * e.z)), + ); await Future.delayed( Duration(milliseconds: CrashTuning.stillnessSampleWindowMs), diff --git a/lib/services/emergency_orchestrator.dart b/lib/services/emergency_orchestrator.dart index 5527d6e..c7a43b2 100644 --- a/lib/services/emergency_orchestrator.dart +++ b/lib/services/emergency_orchestrator.dart @@ -70,6 +70,7 @@ class SOSStatusMessage { class SOSState { final SOSPhase phase; final int countdownSeconds; + final int crashSeverityHint; final LocationFix? location; final TriageResult? triageResult; final List statusLog; @@ -85,6 +86,7 @@ class SOSState { const SOSState({ this.phase = SOSPhase.idle, this.countdownSeconds = 10, + this.crashSeverityHint = 3, this.location, this.triageResult, this.statusLog = const [], @@ -99,6 +101,7 @@ class SOSState { SOSState copyWith({ SOSPhase? phase, int? countdownSeconds, + int? crashSeverityHint, LocationFix? location, TriageResult? triageResult, List? statusLog, @@ -112,6 +115,7 @@ class SOSState { return SOSState( phase: phase ?? this.phase, countdownSeconds: countdownSeconds ?? this.countdownSeconds, + crashSeverityHint: crashSeverityHint ?? this.crashSeverityHint, location: location ?? this.location, triageResult: triageResult ?? this.triageResult, statusLog: statusLog ?? this.statusLog, @@ -152,12 +156,13 @@ class EmergencyOrchestrator extends StateNotifier { Timer? _countdownTimer; final _uuid = const Uuid(); static const Duration _sosLocationTimeout = Duration(seconds: 12); - static const Duration _sosTriageTimeout = Duration(seconds: 10); + // Rulebook budget: cloud must fall through by ~3s, on-device by ~6s total. + static const Duration _sosTriageTimeout = Duration(seconds: 7); static const Duration _dispatchChannelTimeout = Duration(seconds: 8); + static const String _nearbyRelayNotConfiguredDetail = + 'Not sent — nearby responder relay is not configured in this build.'; EmergencyOrchestrator(this._ref) : super(const SOSState()) { - - _restoreState(); _ref.read(crashDetectionServiceProvider).startMonitoring(); // Phase 8: ensure RL bias is loaded before any SOS fires. @@ -183,12 +188,19 @@ class EmergencyOrchestrator extends StateNotifier { } void _log(String message, SOSPhase phase, {bool isError = false}) { - final msg = SOSStatusMessage(message: message, phase: phase, isError: isError); + final msg = SOSStatusMessage( + message: message, + phase: phase, + isError: isError, + ); state = state.copyWith(statusLog: [msg, ...state.statusLog]); appLog.d('🚒 [ORCHESTRATOR] $message'); } - Future startSos({bool isBystander = false}) async { + Future startSos({ + bool isBystander = false, + int crashSeverityHint = 3, + }) async { if (state.phase != SOSPhase.idle) return; final isDriving = _ref.read(drivingModeProvider) == DrivingMode.driving; @@ -196,6 +208,7 @@ class EmergencyOrchestrator extends StateNotifier { state = state.copyWith( phase: SOSPhase.countdown, countdownSeconds: 10, + crashSeverityHint: crashSeverityHint.clamp(1, 5), isBystander: isBystander, incidentId: _uuid.v4(), dispatchChannels: const [], @@ -220,9 +233,9 @@ class EmergencyOrchestrator extends StateNotifier { // Listen for voice cancel in parallel with countdown timer. // If the user says "cancel"/"stop"/locale equivalent → abort SOS. unawaited( - voice - .listenForCancel(listenFor: const Duration(seconds: 9)) - .then((cancelled) { + voice.listenForCancel(listenFor: const Duration(seconds: 9)).then(( + cancelled, + ) { if (cancelled && state.phase == SOSPhase.countdown) { appLog.i('[Orchestrator] Voice cancel detected — aborting SOS'); cancelSos(); @@ -265,7 +278,9 @@ class EmergencyOrchestrator extends StateNotifier { _log(detail, SOSPhase.active, isError: true); state = state.copyWith( phase: SOSPhase.active, - dispatchChannels: state.dispatchChannels.isEmpty ? _initialDispatchRows() : state.dispatchChannels, + dispatchChannels: state.dispatchChannels.isEmpty + ? _initialDispatchRows() + : state.dispatchChannels, ); await _persistState(true); unawaited(EmergencyBeaconService.instance.start()); @@ -283,7 +298,11 @@ class EmergencyOrchestrator extends StateNotifier { .getCurrentLocation() .timeout(_sosLocationTimeout); } catch (e, st) { - appLog.w('[Orchestrator] Location acquisition timed out/failed', error: e, stackTrace: st); + appLog.w( + '[Orchestrator] Location acquisition timed out/failed', + error: e, + stackTrace: st, + ); location = LocationFix( latitude: 0, longitude: 0, @@ -302,6 +321,20 @@ class EmergencyOrchestrator extends StateNotifier { _log(locLine, SOSPhase.gpsLocking, isError: location.source == 'unknown'); if (location.source == 'unknown') { + final fallbackSeverity = state.isBystander + ? 3 + : (state.crashSeverityHint < 3 ? 3 : state.crashSeverityHint); + final fallbackTriage = TriageResult( + functionCall: 'trigger_sos', + location: 'unknown', + severityLevel: fallbackSeverity, + requiredServices: const ['ambulance', 'police'], + firstAidQuery: 'Call 108/112 and follow dispatcher instructions.', + compressedPayload: l10n.orchestratorSmsNoGpsPayload, + thinkingTrace: 'Location unavailable — emergency guidance downgraded.', + isDegradedMode: true, + source: TriageSource.localTier2, + ); _log( l10n.orchestratorManualActionRequired, SOSPhase.dispatching, @@ -310,16 +343,52 @@ class EmergencyOrchestrator extends StateNotifier { state = state.copyWith( phase: SOSPhase.dispatching, dispatchChannels: _initialDispatchRows(), + triageResult: fallbackTriage, + ); + _patchDispatchChannel( + 'mesh', + DispatchChannelLifecycle.skipped, + 'Skipped — no usable GPS fix.', + ); + _patchDispatchChannel( + 'family_link', + DispatchChannelLifecycle.skipped, + 'Skipped — no usable GPS fix.', + ); + _patchDispatchChannel( + 'nearby_services', + DispatchChannelLifecycle.skipped, + _nearbyRelayNotConfiguredDetail, + ); + _patchDispatchChannel( + 'local_log', + DispatchChannelLifecycle.inProgress, + 'Saving incident on device…', + ); + _patchDispatchChannel( + 'sms', + DispatchChannelLifecycle.inProgress, + 'Sending emergency SMS (no GPS)…', ); - _patchDispatchChannel('mesh', DispatchChannelLifecycle.skipped, 'Skipped — no usable GPS fix.'); - _patchDispatchChannel('family_link', DispatchChannelLifecycle.skipped, 'Skipped — no usable GPS fix.'); - _patchDispatchChannel('local_log', DispatchChannelLifecycle.failed, 'Not saved — no usable GPS fix.'); - _patchDispatchChannel('sms', DispatchChannelLifecycle.inProgress, 'Sending emergency SMS (no GPS)…'); - final smsOutcome = await _dispatchSmsWithRetry( + final persistedFuture = _persistIncidentSnapshot( + incidentId: state.incidentId ?? '', + location: location, + triage: fallbackTriage, + ); + final smsFuture = _dispatchSmsWithRetry( l10n.orchestratorSmsNoGpsPayload, lat: null, lng: null, ); + final persisted = await persistedFuture; + _patchDispatchChannel( + 'local_log', + persisted.ok + ? DispatchChannelLifecycle.success + : DispatchChannelLifecycle.failed, + persisted.detail, + ); + final smsOutcome = await smsFuture; _patchDispatchChannel( 'sms', smsOutcome.primaryAutomatedBarMet @@ -340,16 +409,14 @@ class EmergencyOrchestrator extends StateNotifier { return; } - final facilities = await _ref.read(facilityQueryServiceProvider).queryNearby( - location.latitude, - location.longitude, - ); + final facilities = await _ref + .read(facilityQueryServiceProvider) + .queryNearby(location.latitude, location.longitude); state = state.copyWith(nearbyFacilities: facilities); unawaited( - _ref.read(facilitySyncServiceProvider).syncLocalRegion( - location.latitude, - location.longitude, - ), + _ref + .read(facilitySyncServiceProvider) + .syncLocalRegion(location.latitude, location.longitude), ); _log(l10n.orchestratorAiBrief, SOSPhase.triaging); @@ -363,10 +430,15 @@ class EmergencyOrchestrator extends StateNotifier { location: location, isBystander: state.isBystander, languageCode: locale.languageCode, + severityHint: state.isBystander ? 2 : state.crashSeverityHint, ) .timeout(_sosTriageTimeout); } catch (e, st) { - appLog.w('[Orchestrator] Triage timed out/failed — using safety fallback', error: e, stackTrace: st); + appLog.w( + '[Orchestrator] Triage timed out/failed — using safety fallback', + error: e, + stackTrace: st, + ); rawTriage = TriageResult( functionCall: 'dispatch_emergency', location: '${location.latitude},${location.longitude}', @@ -391,13 +463,16 @@ class EmergencyOrchestrator extends StateNotifier { // The gyro service has a 3s rolling buffer so the crash peak is still in // memory even though a few seconds elapsed during GPS lock + triage. final gyroService = _ref.read(gyroscopeFusionServiceProvider); - final gyroPeak = gyroService.peakRadPerSecAt(DateTime.now(), windowMs: 3000); + final gyroPeak = gyroService.peakRadPerSecAt( + DateTime.now(), + windowMs: 3000, + ); final validation = triageValidationAgent.validate( raw: rawTriage, drivingMode: _ref.read(drivingModeProvider), gyroPeakRadPerSec: gyroPeak, - accelSeverityHint: state.isBystander ? 2 : 3, + accelSeverityHint: state.isBystander ? 2 : state.crashSeverityHint, ); final triage = validation.triage; @@ -427,11 +502,31 @@ class EmergencyOrchestrator extends StateNotifier { final mesh = _ref.read(meshNetworkServiceProvider); - _patchDispatchChannel('mesh', DispatchChannelLifecycle.inProgress, 'Broadcasting BLE beacon…'); - _patchDispatchChannel('sms', DispatchChannelLifecycle.inProgress, 'Sending emergency SMS…'); - _patchDispatchChannel('local_log', DispatchChannelLifecycle.inProgress, 'Saving incident on device…'); - _patchDispatchChannel('family_link', DispatchChannelLifecycle.inProgress, 'Family tracking link…'); - _patchDispatchChannel('nearby_services', DispatchChannelLifecycle.inProgress, 'Broadcasting to nearby services…'); + _patchDispatchChannel( + 'mesh', + DispatchChannelLifecycle.inProgress, + 'Broadcasting BLE beacon…', + ); + _patchDispatchChannel( + 'sms', + DispatchChannelLifecycle.inProgress, + 'Sending emergency SMS…', + ); + _patchDispatchChannel( + 'local_log', + DispatchChannelLifecycle.inProgress, + 'Saving incident on device…', + ); + _patchDispatchChannel( + 'family_link', + DispatchChannelLifecycle.inProgress, + 'Family tracking link…', + ); + _patchDispatchChannel( + 'nearby_services', + DispatchChannelLifecycle.skipped, + _nearbyRelayNotConfiguredDetail, + ); // Phase 9: Automated alerts and calling unawaited(_notifyUser()); @@ -454,38 +549,50 @@ class EmergencyOrchestrator extends StateNotifier { return await future.timeout( _dispatchChannelTimeout, onTimeout: () { - _patchDispatchChannel(id, DispatchChannelLifecycle.failed, timeoutDetail); + _patchDispatchChannel( + id, + DispatchChannelLifecycle.failed, + timeoutDetail, + ); return fallback; }, ); } catch (_) { - _patchDispatchChannel(id, DispatchChannelLifecycle.failed, failureDetail); + _patchDispatchChannel( + id, + DispatchChannelLifecycle.failed, + failureDetail, + ); return fallback; } } - final meshFuture = guard( - id: 'mesh', - future: mesh.startBroadcasting( - triage.compressedPayload, - lat: location.latitude, - lng: location.longitude, - severity: triage.severityLevel, - services: triage.requiredServices, - ), - fallback: false, - timeoutDetail: 'Mesh timed out — continue with SMS and manual action.', - failureDetail: 'Mesh failed — Bluetooth off, unsupported, or error.', - ).then((meshOk) { - _patchDispatchChannel( - '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.', - ); - return meshOk; - }); + final meshFuture = + guard( + id: 'mesh', + future: mesh.startBroadcasting( + triage.compressedPayload, + lat: location.latitude, + lng: location.longitude, + severity: triage.severityLevel, + services: triage.requiredServices, + ), + fallback: false, + timeoutDetail: + 'Mesh timed out — continue with SMS and manual action.', + failureDetail: 'Mesh failed — Bluetooth off, unsupported, or error.', + ).then((meshOk) { + _patchDispatchChannel( + '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.', + ); + return meshOk; + }); // Build the rich, dispatcher-friendly SMS body. Falls back to the legacy // compressedPayload if the structured builder fails (must never throw). @@ -500,92 +607,93 @@ class EmergencyOrchestrator extends StateNotifier { ) .timeout(const Duration(seconds: 3)); } catch (e, st) { - appLog.w('[Orchestrator] structured SMS build failed — using legacy payload', - error: e, stackTrace: st); + appLog.w( + '[Orchestrator] structured SMS build failed — using legacy payload', + error: e, + stackTrace: st, + ); smsBody = triage.compressedPayload; } - final smsFuture = guard( - id: 'sms', - future: _dispatchSmsWithRetry( - smsBody, - lat: location.latitude, - lng: location.longitude, - ), - fallback: const SmsDispatchOutcome( - deviceDirectSmsSent: false, - backendRelayAccepted: false, - primaryAutomatedBarMet: false, - proofLevel: SmsDispatchProofLevel.none, - detail: 'SMS timed out — use dialer/manual SMS now.', - ), - timeoutDetail: 'SMS timed out — use dialer/manual SMS now.', - failureDetail: 'SMS failed — use dialer/manual SMS now.', - ).then((smsOutcome) { - _patchDispatchChannel( - 'sms', - smsOutcome.primaryAutomatedBarMet - ? DispatchChannelLifecycle.success - : DispatchChannelLifecycle.failed, - smsOutcome.detail, - ); - return smsOutcome; - }); - - final persistedFuture = guard<({bool ok, String detail})>( - id: 'local_log', - future: _persistIncidentSnapshot( - incidentId: state.incidentId ?? '', - location: location, - triage: triage, - ), - fallback: (ok: false, detail: 'Local log timed out — incident not saved.'), - timeoutDetail: 'Local log timed out — incident not saved.', - failureDetail: 'Local log failed — incident not saved.', - ).then((persisted) { - _patchDispatchChannel( - 'local_log', - persisted.ok ? DispatchChannelLifecycle.success : DispatchChannelLifecycle.failed, - persisted.detail, - ); - return persisted; - }); + final smsFuture = + guard( + id: 'sms', + future: _dispatchSmsWithRetry( + smsBody, + lat: location.latitude, + lng: location.longitude, + ), + fallback: const SmsDispatchOutcome( + deviceDirectSmsSent: false, + backendRelayAccepted: false, + primaryAutomatedBarMet: false, + proofLevel: SmsDispatchProofLevel.none, + detail: 'SMS timed out — use dialer/manual SMS now.', + ), + timeoutDetail: 'SMS timed out — use dialer/manual SMS now.', + failureDetail: 'SMS failed — use dialer/manual SMS now.', + ).then((smsOutcome) { + _patchDispatchChannel( + 'sms', + smsOutcome.primaryAutomatedBarMet + ? DispatchChannelLifecycle.success + : DispatchChannelLifecycle.failed, + smsOutcome.detail, + ); + return smsOutcome; + }); - final familyFuture = guard<({bool ok, String detail})>( - id: 'family_link', - future: _ref.read(familyTrackingServiceProvider).registerAndNotifyContact( + final persistedFuture = + guard<({bool ok, String detail})>( + id: 'local_log', + future: _persistIncidentSnapshot( incidentId: state.incidentId ?? '', location: location, triage: triage, ), - fallback: (ok: false, detail: 'Family link timed out — share manually if needed.'), - timeoutDetail: 'Family link timed out — share manually if needed.', - failureDetail: 'Family link failed — share manually if needed.', - ).then((family) { - _patchDispatchChannel( - 'family_link', - family.ok ? DispatchChannelLifecycle.success : DispatchChannelLifecycle.failed, - family.detail, - ); - return family; - }); - - final nearbyFuture = guard( - id: 'nearby_services', - future: Future.delayed(const Duration(seconds: 3), () => true), - fallback: false, - timeoutDetail: 'Nearby services broadcast timed out.', - failureDetail: 'Nearby services broadcast failed.', - ).then((ok) { - _patchDispatchChannel( - 'nearby_services', - ok ? DispatchChannelLifecycle.success : DispatchChannelLifecycle.failed, - ok - ? 'Emergency alert broadcasted to nearby facilities and responders ✓' - : 'Could not complete nearby services broadcast.', - ); - return ok; - }); + fallback: ( + ok: false, + detail: 'Local log timed out — incident not saved.', + ), + timeoutDetail: 'Local log timed out — incident not saved.', + failureDetail: 'Local log failed — incident not saved.', + ).then((persisted) { + _patchDispatchChannel( + 'local_log', + persisted.ok + ? DispatchChannelLifecycle.success + : DispatchChannelLifecycle.failed, + persisted.detail, + ); + return persisted; + }); + + final familyFuture = + guard<({bool ok, String detail})>( + id: 'family_link', + future: _ref + .read(familyTrackingServiceProvider) + .registerAndNotifyContact( + incidentId: state.incidentId ?? '', + location: location, + triage: triage, + ), + fallback: ( + ok: false, + detail: 'Family link timed out — share manually if needed.', + ), + timeoutDetail: 'Family link timed out — share manually if needed.', + failureDetail: 'Family link failed — share manually if needed.', + ).then((family) { + _patchDispatchChannel( + 'family_link', + family.ok + ? DispatchChannelLifecycle.success + : DispatchChannelLifecycle.failed, + family.detail, + ); + return family; + }); List results; try { @@ -594,12 +702,17 @@ class EmergencyOrchestrator extends StateNotifier { smsFuture, persistedFuture, familyFuture, - nearbyFuture, ]).timeout(_dispatchChannelTimeout + const Duration(seconds: 1)); } catch (e, st) { // Absolute guard: never hang in dispatching. - appLog.w('[Orchestrator] Dispatch futures did not complete in time', error: e, stackTrace: st); - await failOpenToActive('Dispatch timed out — take manual action (dial emergency number).'); + appLog.w( + '[Orchestrator] Dispatch futures did not complete in time', + error: e, + stackTrace: st, + ); + await failOpenToActive( + 'Dispatch timed out — take manual action (dial emergency number).', + ); return; } @@ -652,12 +765,15 @@ class EmergencyOrchestrator extends StateNotifier { // Phase 7: post-dispatch voice briefing — the driver hears what was sent. if (state.wasInDrivingMode) { final voice = _ref.read(voiceAssistantServiceProvider); - unawaited(voice.speakTriageSummary( - severity: triage.severityLevel, - services: triage.requiredServices, - locationCoords: '${location.latitude.toStringAsFixed(2)}, ' - '${location.longitude.toStringAsFixed(2)}', - )); + unawaited( + voice.speakTriageSummary( + severity: triage.severityLevel, + services: triage.requiredServices, + locationCoords: + '${location.latitude.toStringAsFixed(2)}, ' + '${location.longitude.toStringAsFixed(2)}', + ), + ); } } @@ -717,14 +833,18 @@ class EmergencyOrchestrator extends StateNotifier { ), DispatchChannelRow( id: 'nearby_services', - title: 'Nearby Services', + title: 'Nearby responder relay', lifecycle: DispatchChannelLifecycle.pending, detail: 'Waiting…', ), ]; } - void _patchDispatchChannel(String id, DispatchChannelLifecycle lifecycle, String detail) { + void _patchDispatchChannel( + String id, + DispatchChannelLifecycle lifecycle, + String detail, + ) { final list = List.from(state.dispatchChannels); final i = list.indexWhere((e) => e.id == id); if (i >= 0) { @@ -747,7 +867,8 @@ class EmergencyOrchestrator extends StateNotifier { try { final now = DateTime.now().toIso8601String(); final svc = triage.requiredServices.join(','); - final extended = await PrivacyConsentService.extendedRetentionForUploads(); + final extended = + await PrivacyConsentService.extendedRetentionForUploads(); await appDb.execute( '''INSERT INTO reported_incidents ( id, latitude, longitude, severity, services_needed, status, reported_at, created_at, extended_retention @@ -795,7 +916,8 @@ class EmergencyOrchestrator extends StateNotifier { return 'Saved on phone — enable Supabase anonymous auth for cloud backup.'; } - void triggerSOS() => startSos(); + void triggerSOS({int crashSeverityHint = 3}) => + startSos(crashSeverityHint: crashSeverityHint); void cancelSOS() => cancelSos(); Future _callEmergencyContact() async { @@ -815,7 +937,11 @@ class EmergencyOrchestrator extends StateNotifier { appLog.w('[Orchestrator] Could not launch dialer for $contact'); } } catch (e, st) { - appLog.e('[Orchestrator] Error launching dialer', error: e, stackTrace: st); + appLog.e( + '[Orchestrator] Error launching dialer', + error: e, + stackTrace: st, + ); } } @@ -826,15 +952,19 @@ class EmergencyOrchestrator extends StateNotifier { Future _publishSosToFamilyCircle(LocationFix location) async { try { final profile = _ref.read(userProfileProvider); - await _ref.read(familyCircleServiceProvider.notifier).startPublishing( + await _ref + .read(familyCircleServiceProvider.notifier) + .startPublishing( mode: FamilyPublishMode.sos, displayName: profile.fullName.trim().isEmpty ? 'RoadSOS user' : profile.fullName.trim(), ); } catch (e, st) { - appLog.d('[Orchestrator] family circle SOS publish failed', - stackTrace: st); + appLog.d( + '[Orchestrator] family circle SOS publish failed', + stackTrace: st, + ); } } @@ -869,9 +999,10 @@ class EmergencyOrchestrator extends StateNotifier { } } -final emergencyOrchestratorProvider = StateNotifierProvider((ref) { - return EmergencyOrchestrator(ref); -}); +final emergencyOrchestratorProvider = + StateNotifierProvider((ref) { + return EmergencyOrchestrator(ref); + }); final voiceAssistantServiceProvider = Provider((ref) { return VoiceAssistantService(); diff --git a/lib/ui/first_aid_screen.dart b/lib/ui/first_aid_screen.dart index 0b1bcb4..545cd93 100644 --- a/lib/ui/first_aid_screen.dart +++ b/lib/ui/first_aid_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../l10n/app_localizations.dart'; import '../services/first_aid_store.dart'; class FirstAidScreen extends ConsumerStatefulWidget { @@ -50,7 +51,7 @@ class _FirstAidScreenState extends ConsumerState { Future _lookupFirstAid(String query) async { if (query.trim().isEmpty) return; - + setState(() { _isLoading = true; _result = ''; @@ -64,8 +65,10 @@ class _FirstAidScreenState extends ConsumerState { _result = res; }); } catch (e) { + if (!mounted) return; + final l10n = AppLocalizations.of(context)!; setState(() { - _error = 'Could not load first-aid guidance on this device.'; + _error = l10n.firstAidLoadError; }); } finally { if (mounted) { @@ -76,17 +79,20 @@ class _FirstAidScreenState extends ConsumerState { } } - - @override Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Scaffold( backgroundColor: const Color(0xFF0A0A1A), appBar: AppBar( backgroundColor: const Color(0xFF0A0A1A), - title: const Text( - '🩺 First Aid Guide', - style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold), + title: Text( + l10n.firstAidScreenTitle, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), ), iconTheme: const IconThemeData(color: Colors.white), ), @@ -102,11 +108,14 @@ class _FirstAidScreenState extends ConsumerState { controller: _textController, style: const TextStyle(color: Colors.white), decoration: InputDecoration( - hintText: 'Describe injury...', + hintText: l10n.firstAidSearchHint, hintStyle: const TextStyle(color: Colors.white38), filled: true, fillColor: const Color(0xFF1A1A2E), - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none, @@ -142,18 +151,26 @@ class _FirstAidScreenState extends ConsumerState { borderRadius: BorderRadius.circular(12), border: Border.all(color: Colors.white10), boxShadow: [ - BoxShadow(color: Colors.black26, blurRadius: 10, spreadRadius: 2), + BoxShadow( + color: Colors.black26, + blurRadius: 10, + spreadRadius: 2, + ), ], ), child: ListView.separated( shrinkWrap: true, itemCount: _suggestions.length, - separatorBuilder: (context, index) => const Divider(color: Colors.white10, height: 1), + separatorBuilder: (context, index) => + const Divider(color: Colors.white10, height: 1), itemBuilder: (context, index) { final suggestion = _suggestions[index]; return ListTile( dense: true, - title: Text(suggestion, style: const TextStyle(color: Colors.white70)), + title: Text( + suggestion, + style: const TextStyle(color: Colors.white70), + ), onTap: () { _textController.text = suggestion; _lookupFirstAid(suggestion); @@ -182,16 +199,25 @@ class _FirstAidScreenState extends ConsumerState { decoration: BoxDecoration( color: Colors.red.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.red.withValues(alpha: 0.3)), + border: Border.all( + color: Colors.red.withValues(alpha: 0.3), + ), ), child: Row( children: [ - const Icon(Icons.error_outline, color: Colors.redAccent, size: 20), + const Icon( + Icons.error_outline, + color: Colors.redAccent, + size: 20, + ), const SizedBox(width: 8), Expanded( child: Text( _error!, - style: const TextStyle(color: Colors.redAccent, fontSize: 13), + style: const TextStyle( + color: Colors.redAccent, + fontSize: 13, + ), ), ), ], @@ -208,7 +234,9 @@ class _FirstAidScreenState extends ConsumerState { decoration: BoxDecoration( color: const Color(0xFF1A1A2E), borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.red.withValues(alpha: 0.25)), + border: Border.all( + color: Colors.red.withValues(alpha: 0.25), + ), ), child: SingleChildScrollView( child: Column( @@ -216,10 +244,14 @@ class _FirstAidScreenState extends ConsumerState { children: [ Row( children: [ - const Icon(Icons.medical_services_outlined, color: Colors.redAccent, size: 18), + const Icon( + Icons.medical_services_outlined, + color: Colors.redAccent, + size: 18, + ), const SizedBox(width: 8), Text( - 'Verified Medical Solutions', + l10n.firstAidGuidance, style: TextStyle( color: Colors.redAccent.withValues(alpha: 0.8), fontWeight: FontWeight.bold, @@ -234,11 +266,28 @@ class _FirstAidScreenState extends ConsumerState { data: _result, selectable: true, styleSheet: MarkdownStyleSheet( - p: const TextStyle(color: Colors.white, height: 1.6, fontSize: 15), - strong: const TextStyle(color: Colors.redAccent, fontWeight: FontWeight.bold), - listBullet: const TextStyle(color: Colors.redAccent), - h1: const TextStyle(color: Colors.white, fontSize: 22, fontWeight: FontWeight.bold), - h2: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold), + p: const TextStyle( + color: Colors.white, + height: 1.6, + fontSize: 15, + ), + strong: const TextStyle( + color: Colors.redAccent, + fontWeight: FontWeight.bold, + ), + listBullet: const TextStyle( + color: Colors.redAccent, + ), + h1: const TextStyle( + color: Colors.white, + fontSize: 22, + fontWeight: FontWeight.bold, + ), + h2: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), ), ), ], @@ -259,12 +308,15 @@ class _FirstAidScreenState extends ConsumerState { color: Colors.red.withValues(alpha: 0.05), shape: BoxShape.circle, ), - child: const Icon(Icons.health_and_safety, - size: 80, color: Colors.red), + child: const Icon( + Icons.health_and_safety, + size: 80, + color: Colors.red, + ), ), const SizedBox(height: 24), - const Text( - 'AI Injury Identification', + Text( + l10n.firstAidLookupTitle, style: TextStyle( color: Colors.white, fontSize: 20, @@ -273,23 +325,14 @@ class _FirstAidScreenState extends ConsumerState { textAlign: TextAlign.center, ), const SizedBox(height: 12), - const Text( - 'Type an injury to get\nexact, verified first aid solutions.', - style: TextStyle(color: Colors.white54, fontSize: 16), + Text( + l10n.firstAidLookupSubtitle, + style: const TextStyle( + color: Colors.white54, + fontSize: 16, + ), textAlign: TextAlign.center, ), - const SizedBox(height: 32), - Wrap( - spacing: 8, - runSpacing: 8, - alignment: WrapAlignment.center, - children: [ - _buildChip('Severe Bleeding'), - _buildChip('Muscle Tear'), - _buildChip('Brain Injury'), - _buildChip('Sprains'), - ], - ), ], ), ), @@ -299,15 +342,4 @@ class _FirstAidScreenState extends ConsumerState { ), ); } - - Widget _buildChip(String label) { - return ActionChip( - label: Text(label), - onPressed: () => _lookupFirstAid(label), - backgroundColor: const Color(0xFF1A1A2E), - labelStyle: const TextStyle(color: Colors.white70, fontSize: 12), - side: BorderSide(color: Colors.white.withValues(alpha: 0.1)), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - ); - } -} \ No newline at end of file +}