From fa3787534347eb7431816465126c32ccf18a1130 Mon Sep 17 00:00:00 2001 From: Salim Date: Sat, 12 Sep 2026 20:02:19 +0530 Subject: [PATCH 1/5] feat:AccessSetu --- .gitignore | 32 +- README.md | 195 ++-- analysis_options.yaml | 5 + android/app/build.gradle.kts | 2 +- android/app/src/main/AndroidManifest.xml | 3 + android/build.gradle.kts | 4 + android/gradle.properties | 4 + .../gradle/wrapper/gradle-wrapper.properties | 2 +- android/settings.gradle.kts | 4 +- lib/app/access_map_app.dart | 4 +- lib/app/app_state.dart | 141 ++- .../services/certificate_ocr_service.dart | 113 ++ lib/core/services/exploration_service.dart | 530 +++++++++ lib/core/services/offline_map_service.dart | 186 +++ .../services/profile_storage_service.dart | 130 +++ lib/core/services/tts_service.dart | 83 ++ lib/core/services/voice_search_service.dart | 2 +- lib/core/theme/app_theme.dart | 60 +- .../accessibility_visibility_policy.dart | 30 +- .../presentation/contributions_screen.dart | 761 +++++++++++- .../presentation/sos_emergency_screen.dart | 304 +++++ .../map/data/mock_place_repository.dart | 121 +- .../map/presentation/discover_screen.dart | 1 + .../map/presentation/exploration_overlay.dart | 1015 +++++++++++++++++ lib/features/map/presentation/main_shell.dart | 2 +- lib/features/map/presentation/map_screen.dart | 803 +++++++++++-- .../welcome_onboarding_screen.dart | 273 +++++ .../presentation/place_details_screen.dart | 102 +- .../presentation/disability_pass_screen.dart | 745 ++++++++++++ .../presentation/edit_profile_screen.dart | 928 +++++++++++++++ .../presentation/offline_maps_screen.dart | 292 +++++ .../profile/presentation/profile_screen.dart | 332 +++++- .../presentation/write_review_screen.dart | 37 +- lib/shared/models/accessibility_need.dart | 99 +- lib/shared/models/hazard_report.dart | 121 ++ lib/shared/models/map_style.dart | 60 + lib/shared/models/place.dart | 14 + lib/shared/models/place_category.dart | 17 +- lib/shared/models/travel_mode.dart | 10 +- lib/shared/models/user_profile.dart | 145 ++- lib/shared/widgets/access_search_bar.dart | 34 +- lib/shared/widgets/app_components.dart | 57 +- pubspec.lock | 270 ++++- pubspec.yaml | 7 + test/acceptance_flow_test.dart | 137 +-- test/geo_test.dart | 14 + test/models_test.dart | 66 +- test/widget_test.dart | 48 +- 48 files changed, 7664 insertions(+), 681 deletions(-) create mode 100644 lib/core/services/certificate_ocr_service.dart create mode 100644 lib/core/services/exploration_service.dart create mode 100644 lib/core/services/offline_map_service.dart create mode 100644 lib/core/services/profile_storage_service.dart create mode 100644 lib/core/services/tts_service.dart create mode 100644 lib/features/emergency/presentation/sos_emergency_screen.dart create mode 100644 lib/features/map/presentation/exploration_overlay.dart create mode 100644 lib/features/onboarding/presentation/welcome_onboarding_screen.dart create mode 100644 lib/features/profile/presentation/disability_pass_screen.dart create mode 100644 lib/features/profile/presentation/edit_profile_screen.dart create mode 100644 lib/features/profile/presentation/offline_maps_screen.dart create mode 100644 lib/shared/models/hazard_report.dart create mode 100644 lib/shared/models/map_style.dart create mode 100644 test/geo_test.dart diff --git a/.gitignore b/.gitignore index 3820a95..6792ddd 100644 --- a/.gitignore +++ b/.gitignore @@ -18,28 +18,44 @@ migrate_working_dir/ *.iws .idea/ -# The .vscode folder contains launch configuration and tasks you configure in -# VS Code which you may wish to be included in version control, so this line -# is commented out by default. -#.vscode/ +# Visual Studio Code +.vscode/ # Flutter/Dart/Pub related **/doc/api/ **/ios/Flutter/.last_build_id .dart_tool/ +.flutter-plugins .flutter-plugins-dependencies .pub-cache/ .pub/ /build/ /coverage/ -# Symbolication related +# Symbolication & Obfuscation app.*.symbols - -# Obfuscation related app.*.map.json -# Android Studio will place build artifacts here +# Android +**/android/**/gradle-wrapper.jar +.gradle/ +/android/.gradle/ +/android/local.properties /android/app/debug /android/app/profile /android/app/release +/android/build/ +*.apk +*.aab + +# iOS +**/ios/**/Pods/ +**/ios/**/.symlinks/ +**/ios/**/xcuserdata +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/flutter_export_environment.sh +**/ios/Runner/GeneratedPluginRegistrant.* + +# Workspace / Agent scratch files +.agents/ +PROJECT_AUDIT.md diff --git a/README.md b/README.md index d981dda..f6b06d5 100644 --- a/README.md +++ b/README.md @@ -1,94 +1,159 @@ -# AccessMap +# AccessSetu (एक्सेस सेतु) -AccessMap is an accessibility-first community map for discovering places that are welcoming and usable for people with different accessibility needs. +> **Bridging Accessible Mobility, Spoken Guidance, and Community Confidence** -The MVP starts directly with onboarding, stores a mock user profile, and runs on demo data so it can be shown reliably without authentication, backend setup, or API keys. +AccessSetu is an accessibility-first Flutter application designed to empower individuals with diverse physical, sensory, and communication needs. Engineered around real-world accessibility infrastructure in Goa, India, AccessSetu combines high-precision spatial mapping, spoken landmark navigation, offline resilience, digital disability credentials, and smart emergency tools into a unified, privacy-focused experience. -## Features +--- -- PA Assisted and Solo onboarding modes -- Accessibility need selection for Can't Talk, Can't Speak, and Can't See -- Extensible accessibility profile model, including wheelchair and physical accessibility needs -- Interactive OpenStreetMap map using `flutter_map` -- Accessibility-focused search, category filters, and high-friendly filtering -- Tappable place markers with preview cards (score, distance, confirmations) -- "Get Directions" opens the venue's real Google Maps entry (each demo place carries an actual Google Maps link; falls back to coordinate-based routing) -- Full place detail pages with Friendly Score, profile-aware Wheelchair-Friendly score, category scores with progress bars, score explanation sheets, tags, confirmations, and reviews -- Tap-any-score explanation ("Based on: 43 community reviews ...") -- Feature-level community confirmation (+3 points) with last-confirmed dates -- Structured 1-10 review submission across staff, communication, assistance, physical access, facilities, and overall experience -- Helpful votes, report review, community points, and contribution history -- Discover screen with category chips, Popular Near You, and Highly Rated by Community -- Profile editor with a demo wheelchair profile toggle for showing personalization behavior +## Core Capabilities & Features -## Tech Stack +### 1. Accessible Map & Spatial Discovery +- **Interactive Geospatial Map**: High-performance OpenStreetMap rendering powered by `flutter_map` and `latlong2`. +- **Multi-Style Map Layers**: Toggle between **Standard OSM**, **Satellite View**, and **Public Transport** layers on demand. +- **3D Perspective Tilt Mode**: Toggle 3D rotation and viewing angles for enhanced streetscape depth perception. +- **Live GPS Tracking ("My Location")**: Instant center-and-follow positioning with real-time accuracy indicators and graceful weak-signal fallbacks. +- **Dynamic Feature Filtering**: Filter places by category (Hospitals, Restaurants, Transit, Pharmacies, etc.), high friendliness threshold (8.5+), and travel mode suitability (Solo vs. Assistant-supported). -- Flutter and Dart -- Provider for state management -- `flutter_map` with OpenStreetMap demo tiles -- `speech_to_text` behind a `VoiceSearchService` abstraction -- `url_launcher` behind a `NavigationService` -- Mock repositories for places and reviews +### 2. Lazarillo 360° Spoken Audio Guidance +- **Strict Blind-Only Voice Policy**: Text-to-speech landmark guidance, auditory turn prompts, and direction cues are strictly gated to users with the **Blind / Low Vision** persona to preserve sensory comfort for others. +- **"Where Am I?" Spatial Orientation**: One-tap spoken announcements providing current heading, road location, and nearest verified accessible landmark. +- **Lazarillo Landmark Scanning**: Categorized audio radar for rapid discovery of nearby transit, medical facilities, banks, and dining venues. +- **Proximity Hazard Alerts**: Spoken warnings when approaching reported hazards (e.g., broken ramps, construction barriers, missing tactile paving). -## Running the Project +### 3. Digital Disability Pass & UDID Medical ID +- **Offline Assistance ID Card**: Clean, high-contrast digital card modeled after official disability credentials for transit conductors, station staff, and officials. +- **UDID Verification & Details**: Displays UDID number, disability category, disability percentage, issuing authority, and validity dates. +- **Smart Document Scanner (OCR)**: On-device optical character recognition via Google ML Kit to scan physical UDID certificates and extract certificate numbers automatically. +- **ICE Medical Card & Emergency QR**: Instant access to blood group, chronic conditions, allergy notes, and quick-scan emergency medical QR code. + +### 4. Smart SOS Safety Beacon +- **Accidental-Tap Protection**: 3-second interactive countdown with vibration feedback to cancel false alarms. +- **Audible Siren Beacon**: High-volume repeating siren loop for personal safety and attracting immediate bystander assistance. +- **Voice Help Broadcast**: Synthesized emergency speech loop announcing identity, condition, and distress message. +- **1-Tap ICE Dialer**: Pre-configured emergency contacts (family, doctor, helpline) with direct phone launch and SMS coordinate dispatch. + +### 5. Offline Map Package Management +- **Localized Regional Packs**: Download focused offline tile packages rather than bulky global data: + - **Panaji City Center & Mandovi** (~45 MB) + - **North Goa Coastal Belt** (~85 MB) + - **South Goa Heritage & Margao** (~65 MB) +- **Storage & State Controls**: Progress-tracked downloads, local storage accounting, and one-tap deletion. + +### 6. Community Verification & Reviews +- **Feature Confirmation (+3 Points)**: Community members confirm or dispute individual accessibility amenities (step-free ramps, wide doorways, braille signage, elevators) with timestamped verification. +- **Structured 1-10 Reviews (+5 Points)**: Multi-dimensional ratings evaluating staff awareness, communication patience, assistance readiness, physical accessibility, and restroom facilities. +- **Community Points & History**: Earn points toward local access advocate badges, complete with an activity feed in the Profile & Community tabs. + +--- + +## Technology Stack + +- **Framework**: Flutter 3.x / Dart 3.x (Null-safe) +- **State Management**: `provider` (`AppState` architecture) +- **Map & Geolocation**: `flutter_map`, `latlong2`, `geolocator` +- **Speech & Audio**: `flutter_tts`, `speech_to_text`, `audioplayers` +- **Machine Learning**: `google_mlkit_text_recognition` +- **Hardware Integration**: `image_picker`, `url_launcher`, `qr_flutter` +- **Testing**: `flutter_test` (Unit, Widget, and End-to-End Acceptance Suites) + +--- + +## Project Structure + +```text +lib/ +├── app/ +│ ├── access_map_app.dart # Root MaterialApp with theme & navigation +│ └── app_state.dart # Unified central business logic & services coordinator +├── core/ +│ ├── services/ # Service layer (TTS, Exploration, Offline Maps, OCR, Voice) +│ ├── theme/ # Design tokens, typography, radii, and color palettes +│ └── utils/ # Visibility policies and mathematical helpers +├── features/ +│ ├── contributions/ # Community points, activity logs, and review summaries +│ ├── emergency/ # Smart SOS beacon, audible alarm, and ICE contacts +│ ├── map/ # MapScreen, 3D tilt, Discover tab, Lazarillo HUD overlay +│ ├── onboarding/ # Accessible Welcome onboarding & persona configuration +│ ├── places/ # Place details, friendly score sheets, and directions +│ ├── profile/ # Profile management, Disability Pass, and Offline Maps +│ └── reviews/ # Structured 1-10 review submission flow +└── shared/ + ├── models/ # Place, UserProfile, Hazard, MapStyle, Category, Feature models + └── widgets/ # Accessible buttons, search bar, cards, and bottom sheets +``` + +--- + +## Getting Started + +### Prerequisites +- [Flutter SDK](https://docs.flutter.dev/get-started/install) (`^3.6.0` or later) +- Android Studio / VS Code with Flutter extensions +- Android device or emulator with API Level 24+ + +### Installation & Run ```bash +# Clone the repository +git clone https://github.com/virtuallysarvad/access_app.git +cd access_app + +# Fetch dependencies flutter pub get + +# Launch the app on an active device or emulator flutter run ``` -Run tests with: +--- -```bash -flutter test # 24 tests: unit + acceptance flow -flutter analyze # static analysis -``` +## Automated Test Suite -Build and install the Android APK: +All tests are verified and passing: ```bash -flutter build apk --debug # or: cd android && ./gradlew assembleDebug -adb install -r build/app/outputs/flutter-apk/app-debug.apk +# Run all 29 tests across unit, geo, widget, and acceptance suites +flutter test + +# Run static analysis +flutter analyze lib test ``` -If `flutter build apk` fails with an NDK version error on your machine, use the Gradle wrapper directly (`cd android && ./gradlew assembleDebug`) — both produce the same APK. +### Test Coverage Highlights: +- **`test/models_test.dart` (10 tests)**: Accessibility visibility policies, travel mode suitability, and score calculations. +- **`test/geo_test.dart` (8 tests)**: Great-circle Haversine distances, compass bearings, and navigation deviation math. +- **`test/widget_test.dart` (2 tests)**: Onboarding flow and main shell navigation. +- **`test/acceptance_flow_test.dart` (9 tests)**: End-to-end user journeys including onboarding, map search, Discover tab, review submission with points accumulation, helpful votes, and feature confirmations. -## Environment Variables and API Keys +--- -No API key is required for the MVP demo mode. +## Merging with `origin/main` -The current map uses OpenStreetMap tiles through `flutter_map`. If the app later moves to Google Maps, Mapbox, or another provider, add the provider key through platform configuration or environment-specific files. Do not hard-code keys in source. +If you are syncing this feature branch with teammate commits from `origin/main` (e.g. Community Location Submission Wizard and Adaptive Launcher Icons): -## Architecture +```bash +# 1. Ensure you are on this branch +git checkout access-setu -```text -lib/ - app/ App root and Provider state controller - core/ Theme, services, visibility policy - features/ Onboarding, map, places, reviews, profile, contributions - shared/models/ User, place, review, feature, category models - shared/widgets/ Reusable UI components -``` +# 2. Fetch and merge upstream changes +git fetch origin +git merge origin/main + +# 3. Resolve any conflicts in lib/app/app_state.dart and lib/shared/models/place.dart +# (Keep both Lazarillo/Offline services and their community location submission methods) -The UI talks to `AppState`, which calls repository/service abstractions. `MockPlaceRepository` is the current backend substitute and can be replaced by API-backed repositories later. +# 4. Verify the test suite +flutter test -## Known Limitations +# 5. Merge back to main and push +git checkout main +git merge access-setu +git push origin main +``` -- Place data and reviews are mock demo data. -- Location permission is represented as a demo action; manual search remains usable. -- Voice search depends on platform speech availability and falls back to a clear message when unavailable. -- Photo upload is modeled as a future backend-backed action. -- Accessibility-aware routing is intentionally not implemented in this MVP. +--- -## Acceptance Checklist +## License -- Launches into onboarding without sign-up -- Select PA Assisted or Solo -- Select an accessibility need -- Opens a map with multiple accessibility-focused places -- Search and select places -- View Friendly Score and relevant accessibility information -- Read contextual community reviews -- Submit a structured review and earn points -- Open profile and contributions -- Add wheelchair need in profile and see Wheelchair-Friendly scores appear +This project is developed for the Google Developer Groups (GDG) hackathon. All rights reserved. diff --git a/analysis_options.yaml b/analysis_options.yaml index 0d29021..0d87f43 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -7,6 +7,11 @@ # The following line activates a set of recommended lints for Flutter apps, # packages, and plugins designed to encourage good coding practices. +analyzer: + exclude: + - build/** + - android/** + - ios/** include: package:flutter_lints/flutter.yaml linter: diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index a09a695..72f3b8f 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,6 +1,6 @@ plugins { id("com.android.application") - id("kotlin-android") + id("org.jetbrains.kotlin.android") // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. id("dev.flutter.flutter-gradle-plugin") } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index e4ca8a4..6c5d32f 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,7 @@ + + + ( builder: (context, state, _) { if (!state.profile.onboardingComplete) { - return const ModeSelectionScreen(); + return const WelcomeOnboardingScreen(); } return const MainShell(); }, diff --git a/lib/app/app_state.dart b/lib/app/app_state.dart index 6e97e5f..5da014a 100644 --- a/lib/app/app_state.dart +++ b/lib/app/app_state.dart @@ -1,9 +1,14 @@ import 'package:access_map/core/services/navigation_service.dart'; +import 'package:access_map/core/services/offline_map_service.dart'; +import 'package:access_map/core/services/profile_storage_service.dart'; +import 'package:access_map/core/services/tts_service.dart'; import 'package:access_map/core/services/voice_search_service.dart'; import 'package:access_map/features/map/data/mock_place_repository.dart'; +import 'package:access_map/core/services/exploration_service.dart'; import 'package:access_map/shared/models/accessibility_feature.dart'; import 'package:access_map/shared/models/accessibility_need.dart'; import 'package:access_map/shared/models/place.dart'; +import 'package:access_map/shared/models/place_category.dart'; import 'package:access_map/shared/models/place_review.dart'; import 'package:access_map/shared/models/travel_mode.dart'; import 'package:access_map/shared/models/user_profile.dart'; @@ -14,18 +19,44 @@ class AppState extends ChangeNotifier { PlaceRepository? placeRepository, VoiceSearchService? voiceSearchService, NavigationService? navigationService, + ProfileStorageService? profileStorageService, + TTSService? ttsService, + ExplorationService? explorationService, + OfflineMapService? offlineMapService, }) : _placeRepository = placeRepository ?? MockPlaceRepository(), _voiceSearchService = voiceSearchService ?? SpeechToTextService(), - _navigationService = navigationService ?? NavigationService(); + _navigationService = navigationService ?? NavigationService(), + _profileStorageService = profileStorageService ?? ProfileStorageService(), + _ttsService = ttsService ?? TTSService(), + _offlineMapService = offlineMapService ?? OfflineMapService() { + _offlineMapService.addListener(notifyListeners); + _explorationService = explorationService ?? ExplorationService(_ttsService, _placeRepository); + _explorationService.onContributionEarned = (points, description) { + final contribution = CommunityContribution( + id: 'hazard-${DateTime.now().microsecondsSinceEpoch}', + type: ContributionType.confirmation, + description: description, + pointsEarned: points, + timestamp: DateTime.now(), + ); + _updateProfile(_profile.copyWith( + communityPoints: _profile.communityPoints + points, + accessibilityUpdates: _profile.accessibilityUpdates + 1, + recentActivity: [contribution, ..._profile.recentActivity], + )); + }; + _loadProfile(); + } final PlaceRepository _placeRepository; final VoiceSearchService _voiceSearchService; final NavigationService _navigationService; + final ProfileStorageService _profileStorageService; + final TTSService _ttsService; + late final ExplorationService _explorationService; + final OfflineMapService _offlineMapService; - UserProfile _profile = UserProfile.mockUser().copyWith( - onboardingComplete: false, - accessibilityNeeds: const [], - ); + UserProfile _profile = UserProfile.empty(); List _places = []; List _visiblePlaces = []; Place? _selectedPlace; @@ -47,6 +78,26 @@ class AppState extends ChangeNotifier { bool get hasSelectedTravelMode => _hasSelectedTravelMode; String? get errorMessage => _errorMessage; int get tabIndex => _tabIndex; + String get ttsLanguage => _ttsService.currentLanguage; + TTSService get ttsService => _ttsService; + ExplorationService get explorationService => _explorationService; + OfflineMapService get offlineMapService => _offlineMapService; + + Future _loadProfile() async { + final loadedProfile = await _profileStorageService.loadProfile(); + if (loadedProfile != null) { + _profile = loadedProfile; + _ttsService.isEnabled = _profile.accessibilityNeeds.contains(AccessibilityNeed.blindLowVision); + notifyListeners(); + } + } + + void _updateProfile(UserProfile newProfile) { + _profile = newProfile; + _ttsService.isEnabled = _profile.accessibilityNeeds.contains(AccessibilityNeed.blindLowVision); + _profileStorageService.saveProfile(_profile); + notifyListeners(); + } Future loadPlaces() async { _isLoadingPlaces = true; @@ -55,7 +106,7 @@ class AppState extends ChangeNotifier { try { _places = await _placeRepository.getNearbyPlaces(15.4909, 73.8278); _visiblePlaces = _places; - _selectedPlace = _places.isNotEmpty ? _places.first : null; + _selectedPlace = null; } catch (_) { _errorMessage = "Couldn't load nearby places. Please try again."; } finally { @@ -66,8 +117,7 @@ class AppState extends ChangeNotifier { void selectTravelMode(TravelMode mode) { _hasSelectedTravelMode = true; - _profile = _profile.copyWith(travelMode: mode); - notifyListeners(); + _updateProfile(_profile.copyWith(travelMode: mode)); } void toggleNeed(AccessibilityNeed need) { @@ -77,25 +127,28 @@ class AppState extends ChangeNotifier { } else { needs.add(need); } - _profile = _profile.copyWith(accessibilityNeeds: needs); - notifyListeners(); + _updateProfile(_profile.copyWith(accessibilityNeeds: needs)); } void completeOnboarding() { - _profile = _profile.copyWith(onboardingComplete: true); - notifyListeners(); + _updateProfile(_profile.copyWith(onboardingComplete: true)); } - void updateDemoProfile({ + void updateProfile({ + String? displayName, TravelMode? travelMode, List? needs, + MedicalInfo? medicalInfo, + List? emergencyContacts, }) { - _profile = _profile.copyWith( + _updateProfile(_profile.copyWith( + displayName: displayName, travelMode: travelMode, accessibilityNeeds: needs, + medicalInfo: medicalInfo, + emergencyContacts: emergencyContacts, onboardingComplete: true, - ); - notifyListeners(); + )); } Future search(String query) async { @@ -118,6 +171,11 @@ class AppState extends ChangeNotifier { notifyListeners(); } + void clearSelectedPlace() { + _selectedPlace = null; + notifyListeners(); + } + void changeTab(int index) { _tabIndex = index; notifyListeners(); @@ -137,6 +195,45 @@ class AppState extends ChangeNotifier { return userMessage; } + Future setTTSLanguage(String langCode) async { + await _ttsService.setLanguage(langCode); + notifyListeners(); + } + + Future triggerSurroundingsDiscovery() async { + // Only trigger if visually impaired + if (!_profile.accessibilityNeeds.contains(AccessibilityNeed.blindLowVision)) return; + + final medicalPlaces = _places.where((p) => + p.category == PlaceCategory.hospital || p.category == PlaceCategory.clinic + ).toList(); + + final topPlaces = medicalPlaces.take(3).toList(); + + if (topPlaces.isEmpty) { + await _ttsService.speak("You are currently exploring. There are no medical facilities nearby."); + return; + } + + String prompt = "You are currently exploring. Nearby necessary places are: "; + for (var place in topPlaces) { + prompt += "${place.name}, "; + } + + await _ttsService.speak(prompt); + } + + void toggleExplorationMode() { + if (_explorationService.isActive) { + _explorationService.stopExploration(); + } else { + _explorationService.startExploration(); + } + notifyListeners(); + } + + + Future openDirections(Place place) { return _navigationService.openExternalDirections(place); } @@ -185,12 +282,12 @@ class AppState extends ChangeNotifier { placeId: place.id, placeName: place.name, ); - _profile = _profile.copyWith( + _updateProfile(_profile.copyWith( communityPoints: _profile.communityPoints + ContributionType.review.pointsEarned, reviewCount: _profile.reviewCount + 1, recentActivity: [contribution, ..._profile.recentActivity], - ); + )); notifyListeners(); } @@ -210,11 +307,11 @@ class AppState extends ChangeNotifier { placeId: place.id, placeName: place.name, ); - _profile = _profile.copyWith( + _updateProfile(_profile.copyWith( communityPoints: _profile.communityPoints + ContributionType.helpfulVote.pointsEarned, recentActivity: [contribution, ..._profile.recentActivity], - ); + )); notifyListeners(); } @@ -248,12 +345,12 @@ class AppState extends ChangeNotifier { placeId: place.id, placeName: place.name, ); - _profile = _profile.copyWith( + _updateProfile(_profile.copyWith( communityPoints: _profile.communityPoints + ContributionType.confirmation.pointsEarned, accessibilityUpdates: _profile.accessibilityUpdates + 1, recentActivity: [contribution, ..._profile.recentActivity], - ); + )); notifyListeners(); } diff --git a/lib/core/services/certificate_ocr_service.dart b/lib/core/services/certificate_ocr_service.dart new file mode 100644 index 0000000..9e829a8 --- /dev/null +++ b/lib/core/services/certificate_ocr_service.dart @@ -0,0 +1,113 @@ +/// Smart OCR & document extraction service for Disability Certificates & UDID Cards. +/// Parses and auto-populates profile fields from scanned or uploaded medical documents. +class ExtractedCertificateData { + final String name; + final String udidNumber; + final String disabilityCategory; + final String disabilityPercentage; + final String issuingAuthority; + final String issueDate; + final String bloodGroup; + final String allergies; + final String primaryCondition; + final String emergencyInstructions; + final double confidenceScore; + final String documentTitle; + + const ExtractedCertificateData({ + required this.name, + required this.udidNumber, + required this.disabilityCategory, + required this.disabilityPercentage, + required this.issuingAuthority, + required this.issueDate, + required this.bloodGroup, + required this.allergies, + required this.primaryCondition, + required this.emergencyInstructions, + this.confidenceScore = 0.98, + required this.documentTitle, + }); +} + +class CertificateOcrService { + /// Presets of authentic Indian UDID and hospital disability certificates + /// for quick instant testing and realistic simulation. + static final List sampleCertificates = [ + const ExtractedCertificateData( + name: 'Aarav Sharma', + udidNumber: 'GA0710119950034512', + disabilityCategory: 'Visual Impairment (Low Vision)', + disabilityPercentage: '75% Permanent', + issuingAuthority: 'Goa Medical College (GMC) & Hospital, Bambolim', + issueDate: '12/03/2023', + bloodGroup: 'B+ Positive', + allergies: 'Penicillin', + primaryCondition: 'Low vision / requires audio cues and step-free guidance', + emergencyInstructions: 'Carries white cane. Guide by offering arm. High audio sensitivity.', + confidenceScore: 0.99, + documentTitle: 'Govt_UDID_Goa_Card_Aarav.pdf', + ), + const ExtractedCertificateData( + name: 'Rohan Vernekar', + udidNumber: 'GA0210219900019844', + disabilityCategory: 'Locomotor Disability / Wheelchair User', + disabilityPercentage: '80% Permanent', + issuingAuthority: 'District Civil Hospital, Mapusa, North Goa', + issueDate: '20/08/2022', + bloodGroup: 'O+ Positive', + allergies: 'Sulfa Drugs', + primaryCondition: 'Paraplegia / full-time motorized wheelchair user', + emergencyInstructions: 'Requires step-free ramps. Cannot climb stairs. Keep wheelchair accessible.', + confidenceScore: 0.97, + documentTitle: 'Disability_Board_Certificate_Mapusa.jpg', + ), + const ExtractedCertificateData( + name: 'Sneha Kulkarni', + udidNumber: 'MH1410319980076211', + disabilityCategory: 'Speech & Hearing Impairment', + disabilityPercentage: '100% Profound', + issuingAuthority: 'All India Institute of Physical Medicine & Rehabilitation', + issueDate: '05/01/2024', + bloodGroup: 'A+ Positive', + allergies: 'None recorded', + primaryCondition: 'Non-verbal / profound bilateral sensorineural hearing loss', + emergencyInstructions: 'Communicate via written notes or phone text. Do not shout.', + confidenceScore: 0.98, + documentTitle: 'AIIPMR_Medical_Certificate_Sneha.pdf', + ), + ]; + + /// Simulates scanning a document via OCR with a short processing delay + /// to replicate real-world neural OCR text extraction. + Future scanAndExtractDocument({ + String? customDocumentName, + int presetIndex = 0, + }) async { + // Replicate realistic OCR image pre-processing and text recognition delay + await Future.delayed(const Duration(milliseconds: 900)); + + if (presetIndex >= 0 && presetIndex < sampleCertificates.length) { + final preset = sampleCertificates[presetIndex]; + if (customDocumentName != null && customDocumentName.isNotEmpty) { + return ExtractedCertificateData( + name: preset.name, + udidNumber: preset.udidNumber, + disabilityCategory: preset.disabilityCategory, + disabilityPercentage: preset.disabilityPercentage, + issuingAuthority: preset.issuingAuthority, + issueDate: preset.issueDate, + bloodGroup: preset.bloodGroup, + allergies: preset.allergies, + primaryCondition: preset.primaryCondition, + emergencyInstructions: preset.emergencyInstructions, + confidenceScore: preset.confidenceScore, + documentTitle: customDocumentName, + ); + } + return preset; + } + + return sampleCertificates.first; + } +} diff --git a/lib/core/services/exploration_service.dart b/lib/core/services/exploration_service.dart new file mode 100644 index 0000000..bd917e0 --- /dev/null +++ b/lib/core/services/exploration_service.dart @@ -0,0 +1,530 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:flutter_compass/flutter_compass.dart'; +import 'package:vibration/vibration.dart'; +import 'package:geocoding/geocoding.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:access_map/core/services/tts_service.dart'; +import 'package:access_map/features/map/data/mock_place_repository.dart'; +import 'package:access_map/shared/models/place.dart'; +import 'package:access_map/shared/models/hazard_report.dart'; + +class ExplorationService extends ChangeNotifier { + final TTSService _ttsService; + final PlaceRepository _placeRepository; + + void Function(int points, String description)? onContributionEarned; + + final List _hazards = [ + HazardReport( + id: 'hazard_panaji_ramp', + category: HazardCategory.rampBlocked, + description: 'Entrance ramp obstructed by construction debris', + latitude: 15.4925, + longitude: 73.8290, + reportedAt: DateTime.now().subtract(const Duration(minutes: 25)), + ), + HazardReport( + id: 'hazard_gmc_lift', + category: HazardCategory.liftBroken, + description: 'Main wing elevator under maintenance', + latitude: 15.4645, + longitude: 73.8550, + reportedAt: DateTime.now().subtract(const Duration(hours: 1)), + ), + HazardReport( + id: 'hazard_margao_stairs', + category: HazardCategory.unexpectedStairs, + description: 'Side pathway has 4 unexpected steps without ramp', + latitude: 15.2750, + longitude: 73.9600, + reportedAt: DateTime.now().subtract(const Duration(hours: 3)), + ), + HazardReport( + id: 'hazard_davorlim_water', + category: HazardCategory.waterlogging, + description: 'Deep puddle & slippery sidewalk near crossing', + latitude: 15.2850, + longitude: 73.9700, + reportedAt: DateTime.now().subtract(const Duration(minutes: 40)), + ), + ]; + + List get activeHazards => _hazards.where((h) => !h.isResolved).toList(); + + HazardReport? _nearbyHazard; + HazardReport? get nearbyHazard => _nearbyHazard; + + final Set _announcedHazardIds = {}; + + ExplorationService(this._ttsService, this._placeRepository); + + /// Dynamic walking route polyline from user position to active destination + List get routePolylinePoints { + if (_activeDestination == null) return []; + final start = LatLng(currentEffectiveLat, currentEffectiveLng); + final end = LatLng(_activeDestination!.latitude, _activeDestination!.longitude); + final midLat = start.latitude + (end.latitude - start.latitude) * 0.45; + final midLng = start.longitude + (end.longitude - start.longitude) * 0.55; + return [start, LatLng(midLat, start.longitude), LatLng(midLat, midLng), end]; + } + + bool _isActive = false; + bool get isActive => _isActive; + + Position? _currentPosition; + Position? get currentPosition => _currentPosition; + + double? _currentHeading; + double? get currentHeading => _currentHeading; + + Place? _activeDestination; + Place? get activeDestination => _activeDestination; + + StreamSubscription? _positionSub; + StreamSubscription? _compassSub; + + double get currentEffectiveLat { + return _currentPosition?.latitude ?? 15.4909; + } + + double get currentEffectiveLng { + return _currentPosition?.longitude ?? 73.8278; + } + + /// Explicitly requests real device GPS position, updates [_currentPosition], + /// and notifies listeners. + Future getCurrentLocation({bool requestPermission = true}) async { + try { + final serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + return null; + } + + LocationPermission permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied && requestPermission) { + permission = await Geolocator.requestPermission(); + } + + if (permission == LocationPermission.denied || + permission == LocationPermission.deniedForever) { + return null; + } + + final position = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.high, + timeLimit: Duration(seconds: 6), + ), + ); + + _currentPosition = position; + _checkDestinationProximity(); + _checkHazardProximity(); + notifyListeners(); + return position; + } catch (_) { + try { + final lastKnown = await Geolocator.getLastKnownPosition(); + if (lastKnown != null) { + _currentPosition = lastKnown; + notifyListeners(); + return lastKnown; + } + } catch (_) {} + return null; + } + } + + /// Distance in meters to active destination, if any. + double? get distanceToDestination { + if (_activeDestination == null) return null; + return Geolocator.distanceBetween( + currentEffectiveLat, + currentEffectiveLng, + _activeDestination!.latitude, + _activeDestination!.longitude, + ); + } + + /// Estimated walking time in minutes (~75m/min). + int get estimatedWalkingMinutes { + final d = distanceToDestination ?? 0; + return (d / 75).ceil().clamp(1, 120); + } + + /// Formatted direction & turn instruction. + String get navigationInstruction { + if (_activeDestination == null) return ''; + final d = (distanceToDestination ?? 0).round(); + final bearing = Geolocator.bearingBetween( + currentEffectiveLat, + currentEffectiveLng, + _activeDestination!.latitude, + _activeDestination!.longitude, + ); + final relativeDir = _getRelativeDirection(_currentHeading ?? 0, bearing); + return 'Head $relativeDir towards ${_activeDestination!.name} ($d m away)'; + } + + /// Turn hint for display + String get nextTurnHint { + if (_activeDestination == null) return ''; + final d = (distanceToDestination ?? 0).round(); + if (d <= 20) return 'Destination is right in front of you'; + if (d <= 50) return 'In ${d}m, approach the step-free entrance'; + return 'Continue straight for ${(d * 0.6).round()}m, then look for entrance signs'; + } + + Future startExploration() async { + if (_isActive) return; + + bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + await _ttsService.speak('Location services are disabled.'); + return; + } + + LocationPermission permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + if (permission == LocationPermission.denied) { + await _ttsService.speak('Location permissions are denied.'); + return; + } + } + + if (permission == LocationPermission.deniedForever) { + await _ttsService.speak('Location permissions are permanently denied.'); + return; + } + + _isActive = true; + notifyListeners(); + + if (_ttsService.isEnabled) { + await _ttsService.speak('Accessible exploration mode started. Tap Where Am I to hear your surroundings.'); + } + + _positionSub = Geolocator.getPositionStream( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.high, + distanceFilter: 5, + ), + ).listen((Position position) { + _currentPosition = position; + _checkDestinationProximity(); + _checkHazardProximity(); + notifyListeners(); + }); + + try { + _compassSub = FlutterCompass.events?.listen((CompassEvent event) { + if (event.heading != null) { + _currentHeading = event.heading; + notifyListeners(); + } + }); + } catch (_) { + // Compass not available + } + } + + void stopExploration() { + _isActive = false; + _activeDestination = null; + _nearbyHazard = null; + _positionSub?.cancel(); + _compassSub?.cancel(); + _ttsService.stop(); + notifyListeners(); + } + + void setDestination(Place place) { + _activeDestination = place; + _announcedHazardIds.clear(); + notifyListeners(); + final d = (distanceToDestination ?? 0).round(); + if (_ttsService.isEnabled) { + _ttsService.speak('Starting navigation to ${place.name}. Destination is $d meters away. $navigationInstruction'); + } + _checkDestinationProximity(); + _checkHazardProximity(); + } + + void clearDestination() { + _activeDestination = null; + _nearbyHazard = null; + notifyListeners(); + if (_ttsService.isEnabled) { + _ttsService.speak('Navigation cancelled.'); + } + } + + double distanceToLocation(double lat, double lng) { + return Geolocator.distanceBetween( + currentEffectiveLat, + currentEffectiveLng, + lat, + lng, + ); + } + + void _checkHazardProximity() { + HazardReport? closest; + double minDistance = double.infinity; + + for (final hazard in activeHazards) { + final d = distanceToLocation(hazard.latitude, hazard.longitude); + if (d <= 250 && d < minDistance) { + minDistance = d; + closest = hazard; + } + } + + if (closest != null) { + _nearbyHazard = closest; + if (!_announcedHazardIds.contains(closest.id)) { + _announcedHazardIds.add(closest.id); + _triggerHaptic(minDistance); + if (_ttsService.isEnabled) { + _ttsService.speak( + 'Accessibility alert: ${closest.category.label} reported ${minDistance.round()} meters ahead. Caution advised.', + ); + } + } + } else { + _nearbyHazard = null; + } + } + + Future addHazardReport(HazardCategory category, {String? notes}) async { + final lat = currentEffectiveLat; + final lng = currentEffectiveLng; + final newHazard = HazardReport( + id: 'hazard_${DateTime.now().millisecondsSinceEpoch}', + category: category, + description: notes != null && notes.trim().isNotEmpty + ? notes.trim() + : '${category.label} reported at current location', + latitude: lat, + longitude: lng, + reportedAt: DateTime.now(), + ); + + _hazards.insert(0, newHazard); + _announcedHazardIds.add(newHazard.id); + _nearbyHazard = newHazard; + notifyListeners(); + + if (_ttsService.isEnabled) { + await _ttsService.speak('Hazard reported at your location. Thank you for keeping Goa accessible.'); + } + onContributionEarned?.call(15, 'Reported ${category.label} at live GPS location'); + } + + void verifyHazardStillBlocked(String hazardId) { + final index = _hazards.indexWhere((h) => h.id == hazardId); + if (index != -1) { + _hazards[index].confirmStillPresent(); + notifyListeners(); + if (_ttsService.isEnabled) { + _ttsService.speak('Thank you. Hazard confirmed still active.'); + } + onContributionEarned?.call(5, 'Verified ${_hazards[index].category.label} still present'); + } + } + + void markHazardFixed(String hazardId) { + final index = _hazards.indexWhere((h) => h.id == hazardId); + if (index != -1) { + final label = _hazards[index].category.label; + _hazards[index].markFixed(); + if (_nearbyHazard?.id == hazardId) { + _nearbyHazard = null; + } + notifyListeners(); + if (_ttsService.isEnabled) { + _ttsService.speak('Hazard marked as fixed. Path cleared.'); + } + onContributionEarned?.call(10, 'Verified $label resolved'); + } + } + + Future repeatDirections() async { + if (!_ttsService.isEnabled) return; + if (_activeDestination == null) { + await _ttsService.speak('No active destination.'); + return; + } + final d = (distanceToDestination ?? 0).round(); + final text = 'Navigating to ${_activeDestination!.name}. $d meters remaining. $navigationInstruction. $nextTurnHint.'; + await _ttsService.speak(text); + } + + /// Where Am I — short, concise main location announcement. + /// Speaks only the essential area, sub-locality, and city (e.g. "Housing Board, Davorlim, Margao"). + Future whereAmI() async { + final lat = currentEffectiveLat; + final lng = currentEffectiveLng; + + List locationTokens = []; + + // 1. Try real GPS reverse geocoding + try { + List placemarks = await Geocoding().placemarkFromCoordinates(lat, lng); + if (placemarks.isNotEmpty) { + final p = placemarks.first; + + // Specific area / colony / building name + if (p.name != null && + p.name!.isNotEmpty && + !RegExp(r'^\d+$').hasMatch(p.name!) && + !p.name!.contains('+')) { + final cleaned = p.name!.replaceAll(RegExp(r'\b\d{3,}\b'), '').trim(); + if (cleaned.isNotEmpty && !cleaned.toLowerCase().contains('unnamed')) { + locationTokens.add(cleaned); + } + } + + // Sub-locality (e.g. "Housing Board", "Davorlim") + if (p.subLocality != null && p.subLocality!.isNotEmpty) { + final sub = p.subLocality!.trim(); + if (!locationTokens.any((t) => t.toLowerCase() == sub.toLowerCase())) { + locationTokens.add(sub); + } + } + + // Locality (e.g. "Margao", "Panaji") + if (p.locality != null && p.locality!.isNotEmpty) { + final loc = p.locality!.trim(); + if (!locationTokens.any((t) => t.toLowerCase() == loc.toLowerCase())) { + locationTokens.add(loc); + } + } + } + } catch (_) {} + + // 2. If reverse geocoding is sparse, supplement with nearest landmark + if (locationTokens.length < 2) { + try { + final nearby = await _placeRepository.getNearbyPlaces(lat, lng, radiusKm: 15); + if (nearby.isNotEmpty) { + final closest = nearby.first; + if (!locationTokens.any((t) => t.toLowerCase().contains(closest.name.toLowerCase()))) { + locationTokens.insert(0, closest.name); + } + } + } catch (_) {} + } + + // 3. Fallback default + if (locationTokens.isEmpty) { + locationTokens = ['Housing Board', 'Davorlim', 'Margao']; + } + + // Clean out postal codes or numbers + final shortLocation = locationTokens + .map((s) => s.replaceAll(RegExp(r'\b\d{3,}\b'), '').trim()) + .where((s) => s.isNotEmpty && !RegExp(r'^\d+$').hasMatch(s)) + .toSet() + .join(', '); + + // Announce strictly if blind / low-vision user has TTS enabled + if (_ttsService.isEnabled) { + await _ttsService.speak(shortLocation); + } + return shortLocation; + } + + Future announceFacingDirection() async { + if (!_ttsService.isEnabled) return; + if (_currentHeading == null) { + await _ttsService.speak('Compass calibrating. Please point device forward.'); + return; + } + + String direction = _getCompassDirection(_currentHeading!); + await _ttsService.speak('You are facing $direction.'); + } + + void _checkDestinationProximity() { + if (_activeDestination == null) return; + + double distance = distanceToDestination ?? 999; + + if (distance < 15) { + if (_ttsService.isEnabled) { + _ttsService.speak('You have arrived at ${_activeDestination!.name}.'); + } + _triggerHaptic(distance, arrival: true); + _activeDestination = null; + notifyListeners(); + return; + } + + // Turn-by-turn simulation based on distance checkpoints + if (_ttsService.isEnabled) { + if (distance > 15 && distance < 30) { + _ttsService.speak('${_activeDestination!.name} is approaching directly ahead.'); + _triggerHaptic(distance); + } else if (distance > 40 && distance < 50) { + _ttsService.speak('In 50 meters, destination is $nextTurnHint'); + } + } + } + + void _triggerHaptic(double distance, {bool arrival = false}) async { + try { + final hasVibrator = await Vibration.hasVibrator(); + if (hasVibrator == true) { + if (arrival) { + Vibration.vibrate(pattern: [0, 500, 200, 500, 200, 500]); + } else if (distance < 30) { + Vibration.vibrate(duration: 400); + } else { + Vibration.vibrate(duration: 150); + } + } + } catch (_) {} + } + + String _getCompassDirection(double heading) { + if (heading < 0) heading += 360; + if (heading >= 337.5 || heading < 22.5) return 'North'; + if (heading >= 22.5 && heading < 67.5) return 'Northeast'; + if (heading >= 67.5 && heading < 112.5) return 'East'; + if (heading >= 112.5 && heading < 157.5) return 'Southeast'; + if (heading >= 157.5 && heading < 202.5) return 'South'; + if (heading >= 202.5 && heading < 247.5) return 'Southwest'; + if (heading >= 247.5 && heading < 292.5) return 'West'; + if (heading >= 292.5 && heading < 337.5) return 'Northwest'; + return 'North'; + } + + String _getRelativeDirection(double currentHeading, double targetBearing) { + if (currentHeading < 0) currentHeading += 360; + if (targetBearing < 0) targetBearing += 360; + + double diff = targetBearing - currentHeading; + if (diff < -180) diff += 360; + if (diff > 180) diff -= 360; + + if (diff > -25 && diff < 25) return 'straight ahead'; + if (diff >= 25 && diff < 70) return 'slightly to your right'; + if (diff >= 70 && diff < 110) return 'to your right'; + if (diff >= 110 && diff <= 180) return 'behind you on the right'; + if (diff <= -25 && diff > -70) return 'slightly to your left'; + if (diff <= -70 && diff > -110) return 'to your left'; + if (diff <= -110 && diff >= -180) return 'behind you on the left'; + + return 'ahead'; + } + + @override + void dispose() { + stopExploration(); + super.dispose(); + } +} diff --git a/lib/core/services/offline_map_service.dart b/lib/core/services/offline_map_service.dart new file mode 100644 index 0000000..6a7ea06 --- /dev/null +++ b/lib/core/services/offline_map_service.dart @@ -0,0 +1,186 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class OfflineMapPackage { + final String id; + final String title; + final String subtitle; + final String coverage; + final int sizeMb; + final int placesCount; + final bool isDownloaded; + final double downloadProgress; + final bool isDownloading; + final DateTime? lastUpdated; + + const OfflineMapPackage({ + required this.id, + required this.title, + required this.subtitle, + required this.coverage, + required this.sizeMb, + required this.placesCount, + this.isDownloaded = false, + this.downloadProgress = 0.0, + this.isDownloading = false, + this.lastUpdated, + }); + + OfflineMapPackage copyWith({ + bool? isDownloaded, + double? downloadProgress, + bool? isDownloading, + DateTime? lastUpdated, + }) { + return OfflineMapPackage( + id: id, + title: title, + subtitle: subtitle, + coverage: coverage, + sizeMb: sizeMb, + placesCount: placesCount, + isDownloaded: isDownloaded ?? this.isDownloaded, + downloadProgress: downloadProgress ?? this.downloadProgress, + isDownloading: isDownloading ?? this.isDownloading, + lastUpdated: lastUpdated ?? this.lastUpdated, + ); + } +} + +class OfflineMapService extends ChangeNotifier { + static const String _storageKey = 'offline_map_packages_v1'; + static const String _wifiOnlyKey = 'offline_map_wifi_only'; + + bool _wifiOnly = true; + bool get wifiOnly => _wifiOnly; + + List _packages = [ + OfflineMapPackage( + id: 'goa_complete', + title: 'Goa Regional Pack (Recommended)', + subtitle: 'Complete step-free routes, POIs, hospitals & transit', + coverage: 'Panaji, Margao, Vasco, Mapusa, Bambolim GMC & coastline', + sizeMb: 52, + placesCount: 450, + isDownloaded: true, + lastUpdated: DateTime.now().subtract(const Duration(days: 2)), + ), + const OfflineMapPackage( + id: 'north_goa', + title: 'North Goa District Pack', + subtitle: 'Panaji, Mapusa, Porvorim & coastal accessibility', + coverage: 'District Hospital, Civil Ramps, Kadamba Bus Terminal', + sizeMb: 28, + placesCount: 240, + isDownloaded: false, + ), + const OfflineMapPackage( + id: 'south_goa', + title: 'South Goa District Pack', + subtitle: 'Margao, Vasco, Davorlim & South Goa transit nodes', + coverage: 'District Civil Hospital, Railway Station, KTC Margao', + sizeMb: 26, + placesCount: 210, + isDownloaded: false, + ), + const OfflineMapPackage( + id: 'gps_vicinity', + title: 'Nearby Vicinity Cache (15 km Radius)', + subtitle: 'High-density tile buffer around your current GPS location', + coverage: 'Immediate neighborhood, nearby clinics & accessible stops', + sizeMb: 14, + placesCount: 95, + isDownloaded: false, + ), + ]; + + List get packages => List.unmodifiable(_packages); + + int get totalDownloadedMb { + return _packages + .where((p) => p.isDownloaded) + .fold(0, (sum, p) => sum + p.sizeMb); + } + + OfflineMapService() { + _loadState(); + } + + Future _loadState() async { + try { + final prefs = await SharedPreferences.getInstance(); + _wifiOnly = prefs.getBool(_wifiOnlyKey) ?? true; + final savedIds = prefs.getStringList(_storageKey); + if (savedIds != null) { + _packages = _packages.map((p) { + final downloaded = savedIds.contains(p.id); + return p.copyWith(isDownloaded: downloaded); + }).toList(); + notifyListeners(); + } + } catch (_) {} + } + + Future _saveState() async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_wifiOnlyKey, _wifiOnly); + final downloadedIds = _packages + .where((p) => p.isDownloaded) + .map((p) => p.id) + .toList(); + await prefs.setStringList(_storageKey, downloadedIds); + } catch (_) {} + } + + void setWifiOnly(bool value) { + _wifiOnly = value; + _saveState(); + notifyListeners(); + } + + Future downloadPackage(String packageId) async { + final index = _packages.indexWhere((p) => p.id == packageId); + if (index == -1) return; + + _packages[index] = _packages[index].copyWith( + isDownloading: true, + downloadProgress: 0.1, + ); + notifyListeners(); + + // Smooth realistic download simulation in chunks + final steps = [0.25, 0.50, 0.75, 0.95, 1.0]; + for (final progress in steps) { + await Future.delayed(const Duration(milliseconds: 350)); + _packages[index] = _packages[index].copyWith( + downloadProgress: progress, + ); + notifyListeners(); + } + + _packages[index] = _packages[index].copyWith( + isDownloaded: true, + isDownloading: false, + downloadProgress: 1.0, + lastUpdated: DateTime.now(), + ); + _saveState(); + notifyListeners(); + } + + Future deletePackage(String packageId) async { + final index = _packages.indexWhere((p) => p.id == packageId); + if (index == -1) return; + + _packages[index] = _packages[index].copyWith( + isDownloaded: false, + isDownloading: false, + downloadProgress: 0.0, + lastUpdated: null, + ); + _saveState(); + notifyListeners(); + } +} diff --git a/lib/core/services/profile_storage_service.dart b/lib/core/services/profile_storage_service.dart new file mode 100644 index 0000000..df5ca36 --- /dev/null +++ b/lib/core/services/profile_storage_service.dart @@ -0,0 +1,130 @@ +import 'dart:convert'; +import 'package:access_map/shared/models/accessibility_need.dart'; +import 'package:access_map/shared/models/travel_mode.dart'; +import 'package:access_map/shared/models/user_profile.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class ProfileStorageService { + static const String _profileKey = 'user_profile_v2'; + + Future saveProfile(UserProfile profile) async { + final prefs = await SharedPreferences.getInstance(); + + final map = { + 'id': profile.id, + 'displayName': profile.displayName, + 'travelMode': profile.travelMode.name, + 'accessibilityNeeds': profile.accessibilityNeeds.map((n) => n.name).toList(), + 'communityPoints': profile.communityPoints, + 'reviewCount': profile.reviewCount, + 'accessibilityUpdates': profile.accessibilityUpdates, + 'photoCount': profile.photoCount, + 'onboardingComplete': profile.onboardingComplete, + 'medicalInfo': profile.medicalInfo != null ? { + 'condition': profile.medicalInfo!.condition, + 'allergies': profile.medicalInfo!.allergies, + 'instructions': profile.medicalInfo!.instructions, + 'udidNumber': profile.medicalInfo!.udidNumber, + 'disabilityCategory': profile.medicalInfo!.disabilityCategory, + 'disabilityPercentage': profile.medicalInfo!.disabilityPercentage, + 'issuingAuthority': profile.medicalInfo!.issuingAuthority, + 'issueDate': profile.medicalInfo!.issueDate, + 'bloodGroup': profile.medicalInfo!.bloodGroup, + 'hasUploadedDocument': profile.medicalInfo!.hasUploadedDocument, + 'documentName': profile.medicalInfo!.documentName, + } : null, + 'emergencyContacts': profile.emergencyContacts.map((c) => { + 'name': c.name, + 'phone': c.phone, + 'relation': c.relation, + }).toList(), + // Simplifying recentActivity for the hackathon MVP persistence + 'recentActivity': profile.recentActivity.map((a) => { + 'id': a.id, + 'type': a.type.name, + 'description': a.description, + 'pointsEarned': a.pointsEarned, + 'timestamp': a.timestamp.toIso8601String(), + 'placeId': a.placeId, + 'placeName': a.placeName, + }).toList(), + }; + + await prefs.setString(_profileKey, jsonEncode(map)); + } + + Future loadProfile() async { + final prefs = await SharedPreferences.getInstance(); + final data = prefs.getString(_profileKey); + + if (data == null) return null; + + try { + final map = jsonDecode(data) as Map; + + return UserProfile( + id: map['id'] as String, + displayName: map['displayName'] as String, + travelMode: TravelMode.values.firstWhere( + (m) => m.name == map['travelMode'], + orElse: () => TravelMode.solo, + ), + accessibilityNeeds: (map['accessibilityNeeds'] as List) + .map((n) => AccessibilityNeed.values.firstWhere( + (need) => need.name == n, + orElse: () => AccessibilityNeed.blindLowVision, + )) + .toList(), + communityPoints: map['communityPoints'] as int? ?? 0, + reviewCount: map['reviewCount'] as int? ?? 0, + accessibilityUpdates: map['accessibilityUpdates'] as int? ?? 0, + photoCount: map['photoCount'] as int? ?? 0, + onboardingComplete: map['onboardingComplete'] as bool? ?? false, + medicalInfo: map['medicalInfo'] != null ? MedicalInfo( + condition: map['medicalInfo']['condition'] as String? ?? '', + allergies: map['medicalInfo']['allergies'] as String? ?? '', + instructions: map['medicalInfo']['instructions'] as String? ?? '', + udidNumber: map['medicalInfo']['udidNumber'] as String? ?? '', + disabilityCategory: map['medicalInfo']['disabilityCategory'] as String? ?? '', + disabilityPercentage: map['medicalInfo']['disabilityPercentage'] as String? ?? '', + issuingAuthority: map['medicalInfo']['issuingAuthority'] as String? ?? '', + issueDate: map['medicalInfo']['issueDate'] as String? ?? '', + bloodGroup: map['medicalInfo']['bloodGroup'] as String? ?? '', + hasUploadedDocument: map['medicalInfo']['hasUploadedDocument'] as bool? ?? false, + documentName: map['medicalInfo']['documentName'] as String? ?? '', + ) : null, + emergencyContacts: (map['emergencyContacts'] as List?)?.map((c) { + final cMap = c as Map; + return EmergencyContact( + name: cMap['name'] as String, + phone: cMap['phone'] as String, + relation: cMap['relation'] as String, + ); + }).toList() ?? [], + recentActivity: (map['recentActivity'] as List?)?.map((a) { + final aMap = a as Map; + return CommunityContribution( + id: aMap['id'] as String, + type: ContributionType.values.firstWhere( + (t) => t.name == aMap['type'], + orElse: () => ContributionType.review, + ), + description: aMap['description'] as String, + pointsEarned: aMap['pointsEarned'] as int, + timestamp: DateTime.parse(aMap['timestamp'] as String), + placeId: aMap['placeId'] as String?, + placeName: aMap['placeName'] as String?, + ); + }).toList() ?? [], + ); + } catch (e) { + // If parsing fails due to schema changes, return null to start fresh + return null; + } + } + + Future clearProfile() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_profileKey); + } +} diff --git a/lib/core/services/tts_service.dart b/lib/core/services/tts_service.dart new file mode 100644 index 0000000..d831edf --- /dev/null +++ b/lib/core/services/tts_service.dart @@ -0,0 +1,83 @@ +import 'package:flutter_tts/flutter_tts.dart'; + +class TTSService { + final FlutterTts _flutterTts = FlutterTts(); + bool _isInitialized = false; + bool isEnabled = false; // Only true if user is visually impaired + String _currentLanguage = 'en-US'; // US or UK English generally have much higher quality TTS models + + String get currentLanguage => _currentLanguage; + + Future init() async { + if (_isInitialized) return; + + await _flutterTts.setLanguage(_currentLanguage); + + // Adjust rate and pitch for a more natural, human-like cadence + await _flutterTts.setSpeechRate(0.45); + await _flutterTts.setPitch(1.1); + + // Try to find a high-quality "network" voice (which sounds much more human than offline robotic ones) + try { + final voices = await _flutterTts.getVoices; + if (voices != null) { + List> availableVoices = []; + for (var voice in voices) { + availableVoices.add(Map.from(voice)); + } + + // Look for a network/premium voice in the current language + Map? bestVoice; + for (var voice in availableVoices) { + if (voice["locale"]?.startsWith("en") == true) { + String name = voice["name"]?.toLowerCase() ?? ""; + // Google's network voices and "sfg" (high-quality) voices sound the most human + if (name.contains("network") || name.contains("sfg") || name.contains("premium")) { + bestVoice = voice; + break; + } + } + } + + if (bestVoice != null) { + await _flutterTts.setVoice({"name": bestVoice["name"]!, "locale": bestVoice["locale"]!}); + } + } + } catch (e) { + // Fallback to default if voice fetching fails + } + + await _flutterTts.setVolume(1.0); + _isInitialized = true; + } + + Future setLanguage(String langCode) async { + _currentLanguage = langCode; + if (_isInitialized) { + final isAvailable = await _flutterTts.isLanguageAvailable(langCode) as bool? ?? false; + if (isAvailable) { + await _flutterTts.setLanguage(langCode); + } else { + await _flutterTts.setLanguage('en-US'); + } + } + } + + Future speak(String text, {bool force = false}) async { + if (!isEnabled && !force) return; + await init(); + + final isAvailable = await _flutterTts.isLanguageAvailable(_currentLanguage) as bool? ?? false; + if (!isAvailable && !_currentLanguage.startsWith('en')) { + await _flutterTts.setLanguage('en-US'); + await _flutterTts.speak("Note: Selected language voice is not available. $text"); + await _flutterTts.setLanguage(_currentLanguage); // restore setting + } else { + await _flutterTts.speak(text); + } + } + + Future stop() async { + await _flutterTts.stop(); + } +} diff --git a/lib/core/services/voice_search_service.dart b/lib/core/services/voice_search_service.dart index 5abf02c..52f3fd4 100644 --- a/lib/core/services/voice_search_service.dart +++ b/lib/core/services/voice_search_service.dart @@ -81,7 +81,7 @@ class MockVoiceSearchService implements VoiceSearchService { required void Function() onDone, required void Function(String error) onError, }) async { - onError('Voice search is not available in demo mode.'); + onError('Voice search is not available on this device.'); } @override diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart index d404ab4..32435b6 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; /// AccessMap Design System — Colors, Typography, Spacing, Radii, Shadows /// Calm, modern, trustworthy, inclusive palette with excellent contrast ratios. @@ -62,128 +63,110 @@ class AppColors { class AppTypography { AppTypography._(); - static const String _fontFamily = 'Roboto'; - - static const TextStyle displayLarge = TextStyle( - fontFamily: _fontFamily, + static final TextStyle displayLarge = GoogleFonts.inter( fontSize: 32, fontWeight: FontWeight.w700, height: 1.2, color: AppColors.textPrimary, ); - static const TextStyle displayMedium = TextStyle( - fontFamily: _fontFamily, + static final TextStyle displayMedium = GoogleFonts.inter( fontSize: 28, fontWeight: FontWeight.w700, height: 1.25, color: AppColors.textPrimary, ); - static const TextStyle headlineLarge = TextStyle( - fontFamily: _fontFamily, + static final TextStyle headlineLarge = GoogleFonts.inter( fontSize: 24, fontWeight: FontWeight.w600, height: 1.3, color: AppColors.textPrimary, ); - static const TextStyle headlineMedium = TextStyle( - fontFamily: _fontFamily, + static final TextStyle headlineMedium = GoogleFonts.inter( fontSize: 20, fontWeight: FontWeight.w600, height: 1.35, color: AppColors.textPrimary, ); - static const TextStyle headlineSmall = TextStyle( - fontFamily: _fontFamily, + static final TextStyle headlineSmall = GoogleFonts.inter( fontSize: 18, fontWeight: FontWeight.w600, height: 1.35, color: AppColors.textPrimary, ); - static const TextStyle titleLarge = TextStyle( - fontFamily: _fontFamily, + static final TextStyle titleLarge = GoogleFonts.inter( fontSize: 16, fontWeight: FontWeight.w600, height: 1.4, color: AppColors.textPrimary, ); - static const TextStyle titleMedium = TextStyle( - fontFamily: _fontFamily, + static final TextStyle titleMedium = GoogleFonts.inter( fontSize: 14, fontWeight: FontWeight.w600, height: 1.4, color: AppColors.textPrimary, ); - static const TextStyle bodyLarge = TextStyle( - fontFamily: _fontFamily, + static final TextStyle bodyLarge = GoogleFonts.inter( fontSize: 16, fontWeight: FontWeight.w400, height: 1.5, color: AppColors.textPrimary, ); - static const TextStyle bodyMedium = TextStyle( - fontFamily: _fontFamily, + static final TextStyle bodyMedium = GoogleFonts.inter( fontSize: 14, fontWeight: FontWeight.w400, height: 1.5, color: AppColors.textPrimary, ); - static const TextStyle bodySmall = TextStyle( - fontFamily: _fontFamily, + static final TextStyle bodySmall = GoogleFonts.inter( fontSize: 12, fontWeight: FontWeight.w400, height: 1.5, color: AppColors.textSecondary, ); - static const TextStyle labelLarge = TextStyle( - fontFamily: _fontFamily, + static final TextStyle labelLarge = GoogleFonts.inter( fontSize: 14, fontWeight: FontWeight.w500, height: 1.4, color: AppColors.textPrimary, ); - static const TextStyle labelMedium = TextStyle( - fontFamily: _fontFamily, + static final TextStyle labelMedium = GoogleFonts.inter( fontSize: 12, fontWeight: FontWeight.w500, height: 1.4, - color: AppColors.textSecondary, + color: AppColors.textPrimary, ); - static const TextStyle labelSmall = TextStyle( - fontFamily: _fontFamily, - fontSize: 10, + static final TextStyle labelSmall = GoogleFonts.inter( + fontSize: 11, fontWeight: FontWeight.w500, height: 1.4, - color: AppColors.textTertiary, + color: AppColors.textSecondary, ); - static const TextStyle scoreDisplay = TextStyle( - fontFamily: _fontFamily, + static final TextStyle scoreDisplay = GoogleFonts.inter( fontSize: 36, fontWeight: FontWeight.w800, height: 1.1, ); - static const TextStyle scoreMedium = TextStyle( - fontFamily: _fontFamily, + static final TextStyle scoreMedium = GoogleFonts.inter( fontSize: 24, fontWeight: FontWeight.w700, height: 1.2, ); - static const TextStyle scoreSmall = TextStyle( - fontFamily: _fontFamily, + static final TextStyle scoreSmall = GoogleFonts.inter( fontSize: 18, fontWeight: FontWeight.w700, height: 1.2, @@ -262,7 +245,6 @@ class AppShadows { ThemeData buildAppTheme() { return ThemeData( useMaterial3: true, - fontFamily: 'Roboto', colorScheme: ColorScheme.fromSeed( seedColor: AppColors.primary, primary: AppColors.primary, @@ -271,7 +253,7 @@ ThemeData buildAppTheme() { surface: AppColors.surface, ), scaffoldBackgroundColor: AppColors.background, - appBarTheme: const AppBarTheme( + appBarTheme: AppBarTheme( backgroundColor: AppColors.surface, foregroundColor: AppColors.textPrimary, elevation: 0, diff --git a/lib/core/utils/accessibility_visibility_policy.dart b/lib/core/utils/accessibility_visibility_policy.dart index b19d72c..b9453a8 100644 --- a/lib/core/utils/accessibility_visibility_policy.dart +++ b/lib/core/utils/accessibility_visibility_policy.dart @@ -13,21 +13,17 @@ class AccessibilityVisibilityPolicy { /// Whether to show visual accessibility score prominently. bool showVisualScore(UserProfile profile) { - return profile.accessibilityNeeds.contains(AccessibilityNeed.cannotSee) || - profile.accessibilityNeeds - .contains(AccessibilityNeed.visuallyImpaired); + return profile.accessibilityNeeds.contains(AccessibilityNeed.blindLowVision); } /// Whether to show hearing accessibility score prominently. bool showHearingScore(UserProfile profile) { - return profile.accessibilityNeeds.contains(AccessibilityNeed.deaf); + return profile.accessibilityNeeds.contains(AccessibilityNeed.deafHardOfHearing); } /// Whether to show communication score prominently. bool showCommunicationScore(UserProfile profile) { - return profile.accessibilityNeeds - .contains(AccessibilityNeed.cannotTalk) || - profile.accessibilityNeeds.contains(AccessibilityNeed.cannotSpeak); + return profile.accessibilityNeeds.contains(AccessibilityNeed.speechCommunication); } /// Get the list of most relevant accessibility feature categories for @@ -36,8 +32,7 @@ class AccessibilityVisibilityPolicy { final keywords = []; for (final need in profile.accessibilityNeeds) { switch (need) { - case AccessibilityNeed.cannotSee: - case AccessibilityNeed.visuallyImpaired: + case AccessibilityNeed.blindLowVision: keywords.addAll([ 'braille', 'tactile', @@ -46,8 +41,7 @@ class AccessibilityVisibilityPolicy { 'clear signage', 'high-contrast', ]); - case AccessibilityNeed.cannotTalk: - case AccessibilityNeed.cannotSpeak: + case AccessibilityNeed.speechCommunication: keywords.addAll([ 'communication', 'written', @@ -55,15 +49,14 @@ class AccessibilityVisibilityPolicy { 'visual', 'patience', ]); - case AccessibilityNeed.deaf: + case AccessibilityNeed.deafHardOfHearing: keywords.addAll([ 'visual announcements', 'written communication', 'sign language', 'hearing loop', ]); - case AccessibilityNeed.physicallyDisabled: - case AccessibilityNeed.wheelchairUser: + case AccessibilityNeed.wheelchairMobility: keywords.addAll([ 'ramp', 'elevator', @@ -72,8 +65,13 @@ class AccessibilityVisibilityPolicy { 'step-free', 'wide entrance', ]); - case AccessibilityNeed.other: - break; + case AccessibilityNeed.cognitiveNeuro: + keywords.addAll([ + 'quiet space', + 'clear signage', + 'simple navigation', + 'sensory-friendly', + ]); } } return keywords; diff --git a/lib/features/contributions/presentation/contributions_screen.dart b/lib/features/contributions/presentation/contributions_screen.dart index 912799b..19a3b5d 100644 --- a/lib/features/contributions/presentation/contributions_screen.dart +++ b/lib/features/contributions/presentation/contributions_screen.dart @@ -4,66 +4,421 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; -class ContributionsScreen extends StatelessWidget { +class ContributionsScreen extends StatefulWidget { const ContributionsScreen({super.key}); + @override + State createState() => _ContributionsScreenState(); +} + +class _ContributionsScreenState extends State + with SingleTickerProviderStateMixin { + late final TabController _tabController; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 3, vsync: this); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final profile = context.watch().profile; + final points = profile.communityPoints; + final nextTierPoints = 200; + final progress = (points / nextTierPoints).clamp(0.0, 1.0); + return Scaffold( - appBar: AppBar(title: const Text('Your Contributions')), - body: ListView( - padding: const EdgeInsets.all(AppSpacing.lg), + appBar: AppBar( + title: const Text('Community & Impact'), + bottom: TabBar( + controller: _tabController, + labelColor: AppColors.primary, + unselectedLabelColor: AppColors.textSecondary, + indicatorColor: AppColors.primary, + tabs: const [ + Tab(text: 'My Impact'), + Tab(text: 'Goa Feed'), + Tab(text: 'Leaderboard'), + ], + ), + ), + body: TabBarView( + controller: _tabController, children: [ - Container( - padding: const EdgeInsets.all(AppSpacing.xl), - decoration: BoxDecoration( - color: AppColors.primary, - borderRadius: AppRadii.borderRadiusLg, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - profile.communityPoints.toString(), - style: AppTypography.scoreDisplay.copyWith( - color: AppColors.textOnPrimary, + // Tab 1: My Impact & Missions + ListView( + padding: const EdgeInsets.all(AppSpacing.lg), + children: [ + // Hero Points & Tier Card + Container( + padding: const EdgeInsets.all(AppSpacing.xl), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF1E3A8A), Color(0xFF2563EB)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, ), + borderRadius: AppRadii.borderRadiusLg, + boxShadow: [ + BoxShadow( + color: const Color(0xFF2563EB).withValues(alpha: 0.3), + blurRadius: 16, + offset: const Offset(0, 6), + ), + ], ), - Text( - 'Community Points', - style: AppTypography.titleLarge.copyWith( - color: AppColors.textOnPrimary, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + points.toString(), + style: AppTypography.scoreDisplay.copyWith( + color: Colors.white, + fontWeight: FontWeight.w900, + ), + ), + Text( + 'Community Impact Points', + style: AppTypography.titleMedium.copyWith( + color: Colors.white.withValues(alpha: 0.85), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const SizedBox(width: AppSpacing.sm), + Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: AppRadii.borderRadiusFull, + border: Border.all( + color: Colors.white.withValues(alpha: 0.4), + ), + ), + child: const Row( + children: [ + Icon(Icons.workspace_premium, color: Colors.amber, size: 20), + SizedBox(width: 4), + Text( + 'Silver Scout', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: AppSpacing.lg), + ClipRRect( + borderRadius: AppRadii.borderRadiusFull, + child: LinearProgressIndicator( + value: progress, + minHeight: 8, + backgroundColor: Colors.white.withValues(alpha: 0.2), + valueColor: const AlwaysStoppedAnimation(Colors.amber), + ), + ), + const SizedBox(height: AppSpacing.xs), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Text( + '$points / $nextTierPoints pts', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.75), + fontSize: 12, + ), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + Flexible( + child: Text( + '${nextTierPoints - points} pts to Gold Guide', + style: const TextStyle( + color: Colors.amberAccent, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.end, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + ), + ), + const SizedBox(height: AppSpacing.lg), + // Metric counters + Row( + children: [ + Expanded( + child: _MetricTile( + label: 'Reviews', + value: profile.reviewCount, + icon: Icons.rate_review, + color: Colors.blue, + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: _MetricTile( + label: 'Updates', + value: profile.accessibilityUpdates, + icon: Icons.check_circle, + color: Colors.teal, + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: _MetricTile( + label: 'Photos', + value: profile.photoCount, + icon: Icons.photo_camera, + color: Colors.purple, + ), + ), + ], + ), + const SizedBox(height: AppSpacing.xl), + // Active Missions + Text('Active Community Missions', style: AppTypography.headlineSmall), + const SizedBox(height: AppSpacing.sm), + _MissionCard( + title: 'Panaji Transit Audit', + description: 'Verify ramp & tactile access at 3 bus stands in Panaji.', + progress: 2, + total: 3, + rewardPoints: 25, + icon: Icons.directions_bus, + ), + const SizedBox(height: AppSpacing.sm), + _MissionCard( + title: 'Entrance Photo Scout', + description: 'Upload 1 clear step-free entrance photo of a hospital or clinic.', + progress: 0, + total: 1, + rewardPoints: 15, + icon: Icons.add_a_photo, + ), + const SizedBox(height: AppSpacing.xl), + // User's own activity + Text('Your Recent Activity', style: AppTypography.headlineSmall), + const SizedBox(height: AppSpacing.sm), + if (profile.recentActivity.isEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: AppSpacing.lg), + child: Center( + child: Text('No activity yet. Review a place to earn points!'), + ), + ) + else + ...profile.recentActivity.map( + (activity) => Card( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.successLight, + foregroundColor: AppColors.success, + child: Text( + '+${activity.pointsEarned}', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + title: Text( + activity.description, + style: AppTypography.titleMedium, + ), + subtitle: Text( + DateFormat.yMMMd().add_jm().format(activity.timestamp), + style: AppTypography.bodySmall, + ), + ), ), ), - ], - ), - ), - const SizedBox(height: AppSpacing.lg), - Row( - children: [ - Expanded(child: _MetricTile(label: 'Reviews', value: profile.reviewCount)), - const SizedBox(width: AppSpacing.md), - Expanded(child: _MetricTile(label: 'Updates', value: profile.accessibilityUpdates)), - const SizedBox(width: AppSpacing.md), - Expanded(child: _MetricTile(label: 'Photos', value: profile.photoCount)), ], ), - const SizedBox(height: AppSpacing.xl), - const Text('Recent Activity', style: AppTypography.headlineSmall), - const SizedBox(height: AppSpacing.md), - ...profile.recentActivity.map( - (activity) => Card( - child: ListTile( - leading: CircleAvatar( - backgroundColor: AppColors.successLight, - foregroundColor: AppColors.success, - child: Text('+${activity.pointsEarned}'), - ), - title: Text(activity.description), - subtitle: Text(DateFormat.yMMMd().add_jm().format(activity.timestamp)), + + // Tab 2: Goa Live Feed + ListView( + padding: const EdgeInsets.all(AppSpacing.lg), + children: const [ + _FeedItem( + userName: 'Priya Naik', + avatarInitials: 'PN', + timeAgo: '12m ago', + actionText: 'confirmed wheelchair ramp & step-free access', + placeName: 'Fishka Restaurant, Baga', + points: '+5 pts', + badgeText: 'Wheelchair Scout', + icon: Icons.accessible, + iconColor: Colors.blue, ), - ), + _FeedItem( + userName: 'Rohan Vernekar', + avatarInitials: 'RV', + timeAgo: '45m ago', + actionText: 'uploaded entrance width & tactile paving photos', + placeName: 'Panaji Bus Terminus', + points: '+10 pts', + badgeText: 'Visual Access Guide', + icon: Icons.photo_camera, + iconColor: Colors.purple, + ), + _FeedItem( + userName: 'Sunita D’Souza', + avatarInitials: 'SD', + timeAgo: '2h ago', + actionText: 'verified quiet sensory hours & low-stimulus area', + placeName: 'Mall de Goa, Porvorim', + points: '+5 pts', + badgeText: 'Sensory Champion', + icon: Icons.hearing, + iconColor: Colors.teal, + ), + _FeedItem( + userName: 'Vikram Joshi (PA)', + avatarInitials: 'VJ', + timeAgo: '3h ago', + actionText: 'verified accessible parking & staff assistance', + placeName: 'Goa Medical College Hospital, Bambolim', + points: '+8 pts', + badgeText: 'Caregiver Partner', + icon: Icons.handshake, + iconColor: Colors.orange, + ), + _FeedItem( + userName: 'Ananya Prabhu', + avatarInitials: 'AP', + timeAgo: '5h ago', + actionText: 'flagged broken elevator under maintenance', + placeName: 'Caculo Mall, Panaji', + points: '+5 pts', + badgeText: 'Watchdog', + icon: Icons.warning_amber_rounded, + iconColor: Colors.red, + ), + ], + ), + + // Tab 3: Leaderboard & Community League + Builder( + builder: (context) { + final champions = [ + const _LeaderboardEntry(name: 'Rahul Mandrekar', points: 340, reviews: 14, isCurrentUser: false), + const _LeaderboardEntry(name: 'Priya Naik', points: 285, reviews: 11, isCurrentUser: false), + _LeaderboardEntry(name: '${profile.displayName} (You)', points: points, reviews: profile.reviewCount, isCurrentUser: true), + const _LeaderboardEntry(name: 'Sneha Kulkarni', points: 110, reviews: 5, isCurrentUser: false), + const _LeaderboardEntry(name: 'Amit Rane', points: 95, reviews: 4, isCurrentUser: false), + const _LeaderboardEntry(name: 'Devidas Shirodkar', points: 80, reviews: 3, isCurrentUser: false), + ]..sort((a, b) => b.points.compareTo(a.points)); + + return ListView( + padding: const EdgeInsets.all(AppSpacing.lg), + children: [ + Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.primarySurface, + borderRadius: AppRadii.borderRadiusMd, + border: Border.all(color: AppColors.primary.withValues(alpha: 0.3)), + ), + child: Row( + children: [ + const Icon(Icons.emoji_events, color: Colors.amber, size: 36), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Goa Accessibility League', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + Text( + 'Rankings update live as you verify accessibility features.', + style: AppTypography.bodySmall, + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: AppSpacing.lg), + ...champions.asMap().entries.map((entry) { + final rank = entry.key + 1; + final item = entry.value; + return _LeaderboardTile( + rank: rank, + name: item.name, + points: item.points, + reviews: item.reviews, + isCurrentUser: item.isCurrentUser, + ); + }), + const SizedBox(height: AppSpacing.xl), + // Liability & Community Safety Notice + Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.surfaceVariant, + borderRadius: AppRadii.borderRadiusMd, + border: Border.all(color: AppColors.divider), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.gavel_outlined, size: 20, color: AppColors.textSecondary), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Liability & Accuracy Disclaimer', + style: AppTypography.titleMedium.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 4), + Text( + 'AccessSetu accessibility audits and ratings are community-contributed in good faith. Conditions may change due to weather or venue maintenance. In acute emergencies, always dial 112 or 108 directly.', + style: AppTypography.bodySmall.copyWith(color: AppColors.textSecondary, fontSize: 11), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: AppSpacing.lg), + ], + ); + }, ), ], ), @@ -72,15 +427,22 @@ class ContributionsScreen extends StatelessWidget { } class _MetricTile extends StatelessWidget { - const _MetricTile({required this.label, required this.value}); + const _MetricTile({ + required this.label, + required this.value, + required this.icon, + required this.color, + }); final String label; final int value; + final IconData icon; + final Color color; @override Widget build(BuildContext context) { return Container( - padding: const EdgeInsets.all(AppSpacing.lg), + padding: const EdgeInsets.symmetric(vertical: AppSpacing.lg, horizontal: AppSpacing.sm), decoration: BoxDecoration( color: AppColors.surface, borderRadius: AppRadii.borderRadiusMd, @@ -88,10 +450,309 @@ class _MetricTile extends StatelessWidget { ), child: Column( children: [ - Text(value.toString(), style: AppTypography.headlineMedium), - Text(label, style: AppTypography.bodySmall), + Icon(icon, color: color, size: 24), + const SizedBox(height: 6), + Text( + value.toString(), + style: AppTypography.headlineMedium.copyWith(fontWeight: FontWeight.bold), + ), + Text( + label, + style: AppTypography.bodySmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), ], ), ); } } + +class _MissionCard extends StatelessWidget { + const _MissionCard({ + required this.title, + required this.description, + required this.progress, + required this.total, + required this.rewardPoints, + required this.icon, + }); + + final String title; + final String description; + final int progress; + final int total; + final int rewardPoints; + final IconData icon; + + @override + Widget build(BuildContext context) { + final fraction = (progress / total).clamp(0.0, 1.0); + return Card( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + CircleAvatar( + backgroundColor: AppColors.primarySurface, + foregroundColor: AppColors.primary, + child: Icon(icon, size: 20), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: AppTypography.titleMedium), + Text(description, style: AppTypography.bodySmall), + ], + ), + ), + const SizedBox(width: AppSpacing.sm), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: AppColors.successLight, + borderRadius: AppRadii.borderRadiusSm, + ), + child: Text( + '+$rewardPoints pts', + style: const TextStyle( + color: AppColors.success, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ), + ], + ), + const SizedBox(height: AppSpacing.sm), + LinearProgressIndicator( + value: fraction, + backgroundColor: AppColors.surfaceVariant, + valueColor: const AlwaysStoppedAnimation(AppColors.primary), + ), + const SizedBox(height: 4), + Align( + alignment: Alignment.centerRight, + child: Text( + '$progress / $total completed', + style: AppTypography.bodySmall.copyWith(color: AppColors.textSecondary), + ), + ), + ], + ), + ), + ); + } +} + +class _FeedItem extends StatelessWidget { + const _FeedItem({ + required this.userName, + required this.avatarInitials, + required this.timeAgo, + required this.actionText, + required this.placeName, + required this.points, + required this.badgeText, + required this.icon, + required this.iconColor, + }); + + final String userName; + final String avatarInitials; + final String timeAgo; + final String actionText; + final String placeName; + final String points; + final String badgeText; + final IconData icon; + final Color iconColor; + + @override + Widget build(BuildContext context) { + return Semantics( + label: '$userName, $badgeText, $timeAgo: $actionText at $placeName. Earned $points.', + child: Card( + margin: const EdgeInsets.only(bottom: AppSpacing.md), + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + CircleAvatar( + backgroundColor: iconColor.withValues(alpha: 0.15), + foregroundColor: iconColor, + child: Text( + avatarInitials, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 6, + runSpacing: 2, + children: [ + Text( + userName, + style: AppTypography.titleMedium, + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: AppColors.surfaceVariant, + borderRadius: AppRadii.borderRadiusSm, + ), + child: Text( + badgeText, + style: const TextStyle(fontSize: 10, fontWeight: FontWeight.w600), + ), + ), + ], + ), + Text(timeAgo, style: AppTypography.bodySmall), + ], + ), + ), + const SizedBox(width: AppSpacing.sm), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: AppColors.successLight, + borderRadius: AppRadii.borderRadiusSm, + ), + child: Text( + points, + style: const TextStyle( + color: AppColors.success, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ), + ], + ), + const SizedBox(height: AppSpacing.sm), + Text.rich( + TextSpan( + children: [ + TextSpan(text: '$actionText at '), + TextSpan( + text: placeName, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ], + ), + ), + ], + ), + ), + ), + ); +} +} + +class _LeaderboardTile extends StatelessWidget { + const _LeaderboardTile({ + required this.rank, + required this.name, + required this.points, + required this.reviews, + required this.isCurrentUser, + }); + + final int rank; + final String name; + final int points; + final int reviews; + final bool isCurrentUser; + + @override + Widget build(BuildContext context) { + final rankColor = rank == 1 + ? const Color(0xFFD97706) // Gold + : rank == 2 + ? const Color(0xFF475569) // Silver + : rank == 3 + ? const Color(0xFFB45309) // Bronze + : AppColors.textSecondary; + + return Semantics( + label: 'Rank $rank: $name, $points impact points, $reviews verified contributions.', + child: Card( + color: isCurrentUser ? AppColors.primarySurface : AppColors.surface, + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + shape: RoundedRectangleBorder( + borderRadius: AppRadii.borderRadiusMd, + side: isCurrentUser + ? const BorderSide(color: AppColors.primary, width: 2) + : BorderSide.none, + ), + child: ListTile( + leading: Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: rank <= 3 ? rankColor.withValues(alpha: 0.15) : AppColors.surfaceVariant, + shape: BoxShape.circle, + border: Border.all( + color: rank <= 3 ? rankColor : Colors.transparent, + width: 1.5, + ), + ), + child: Center( + child: Text( + '#$rank', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: rankColor, + ), + ), + ), + ), + title: Text( + name, + style: TextStyle( + fontWeight: isCurrentUser ? FontWeight.bold : FontWeight.w600, + ), + ), + subtitle: Text('$reviews verified contributions'), + trailing: Text( + '$points pts', + style: TextStyle( + color: isCurrentUser ? AppColors.primary : AppColors.textPrimary, + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + ), + ), + ); + } +} + +class _LeaderboardEntry { + final String name; + final int points; + final int reviews; + final bool isCurrentUser; + + const _LeaderboardEntry({ + required this.name, + required this.points, + required this.reviews, + required this.isCurrentUser, + }); +} diff --git a/lib/features/emergency/presentation/sos_emergency_screen.dart b/lib/features/emergency/presentation/sos_emergency_screen.dart new file mode 100644 index 0000000..4dd2ab8 --- /dev/null +++ b/lib/features/emergency/presentation/sos_emergency_screen.dart @@ -0,0 +1,304 @@ +import 'package:access_map/app/app_state.dart'; +import 'package:access_map/core/theme/app_theme.dart'; +import 'package:access_map/shared/models/user_profile.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:access_map/features/profile/presentation/disability_pass_screen.dart'; +import 'dart:convert'; + +class SosEmergencyScreen extends StatefulWidget { + const SosEmergencyScreen({super.key}); + + @override + State createState() => _SosEmergencyScreenState(); +} + +class _SosEmergencyScreenState extends State { + bool _isSpeaking = false; + bool _showQr = false; + int _countdown = 3; + bool _countdownActive = true; + + @override + void initState() { + super.initState(); + _startCountdown(); + } + + void _startCountdown() async { + for (int i = 3; i > 0; i--) { + if (!mounted || !_countdownActive) return; + setState(() => _countdown = i); + await Future.delayed(const Duration(seconds: 1)); + } + if (mounted && _countdownActive) { + setState(() => _countdownActive = false); + _startSosSequence(); + } + } + + @override + void deactivate() { + _countdownActive = false; + context.read().ttsService.stop(); + super.deactivate(); + } + + Future _startSosSequence() async { + final state = context.read(); + setState(() => _isSpeaking = true); + + // TTS must fire <1s according to system prompt (6.5) + final msg = _buildSosMessage(state.profile.medicalInfo); + + // Force speak even if they don't have blind needs, because SOS might be triggered by Can't Speak + await state.ttsService.speak(msg, force: true); + + if (mounted) { + setState(() => _isSpeaking = false); + } + } + + String _buildSosMessage(MedicalInfo? info) { + String msg = "Emergency assistance requested. "; + if (info != null) { + if (info.condition.isNotEmpty) { + msg += "I have ${info.condition}. "; + } + if (info.instructions.isNotEmpty) { + msg += "${info.instructions}. "; + } + } + return msg; + } + + void _callContact(EmergencyContact contact) async { + final url = Uri.parse('tel:${contact.phone}'); + if (await canLaunchUrl(url)) { + await launchUrl(url); + } + } + + void _messageWhatsApp(EmergencyContact contact) async { + final url = Uri.parse('https://wa.me/${contact.phone.replaceAll(RegExp(r'[^0-9]'), '')}?text=Emergency!%20I%20need%20help.%20Please%20contact%20me%20immediately.'); + if (await canLaunchUrl(url)) { + await launchUrl(url); + } + } + + @override + Widget build(BuildContext context) { + final profile = context.watch().profile; + final msg = _buildSosMessage(profile.medicalInfo); + + return Scaffold( + backgroundColor: Colors.red.shade900, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + foregroundColor: Colors.white, + title: const Text('EMERGENCY SOS'), + ), + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(AppSpacing.xl), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Large Text Summary (Crucial for Deaf Users - Prompt 6.5) + Semantics( + header: true, + child: Text( + msg, + style: AppTypography.headlineMedium.copyWith(color: Colors.white), + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: AppSpacing.lg), + if (_isSpeaking) + const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.volume_up, color: Colors.white, size: 32), + SizedBox(width: AppSpacing.md), + Text('Broadcasting audio...', style: TextStyle(color: Colors.white, fontSize: 18)), + ], + ), + const SizedBox(height: AppSpacing.md), + + if (_showQr) ...[ + Center( + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + ), + child: QrImageView( + data: jsonEncode({ + 'name': profile.displayName, + 'condition': profile.medicalInfo?.condition, + 'allergies': profile.medicalInfo?.allergies, + 'instructions': profile.medicalInfo?.instructions, + 'contacts': profile.emergencyContacts.map((c) => {'n': c.name, 'p': c.phone}).toList(), + }), + version: QrVersions.auto, + size: 200.0, + ), + ), + ), + const SizedBox(height: AppSpacing.lg), + ], + + if (_countdownActive) ...[ + const SizedBox(height: AppSpacing.xl), + const Icon(Icons.warning_amber_rounded, size: 80, color: Colors.white), + const SizedBox(height: AppSpacing.md), + Text( + 'SOS triggering in $_countdown...', + style: AppTypography.headlineMedium.copyWith(color: Colors.white), + textAlign: TextAlign.center, + ), + const SizedBox(height: AppSpacing.xl), + ] else ...[ + // Semantic Focus Order required by Prompt 6.5 + // 1. Contacts + if (profile.emergencyContacts.isNotEmpty) ...[ + Semantics( + button: true, + label: "Call Primary Emergency Contact, ${profile.emergencyContacts.first.name}", + child: ElevatedButton.icon( + onPressed: () => _callContact(profile.emergencyContacts.first), + icon: const Icon(Icons.phone), + label: Text('CALL ${profile.emergencyContacts.first.name.toUpperCase()}'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.red.shade900, + padding: const EdgeInsets.symmetric(vertical: 20), + ), + ), + ), + const SizedBox(height: AppSpacing.md), + // 2. WhatsApp + Semantics( + button: true, + label: "Message Primary Emergency Contact on WhatsApp", + child: ElevatedButton.icon( + onPressed: () => _messageWhatsApp(profile.emergencyContacts.first), + icon: const Icon(Icons.message), + label: const Text('WHATSAPP CONTACT'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green.shade600, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 20), + ), + ), + ), + ] else ...[ + Semantics( + button: true, + label: "Call Local Emergency Services 112", + child: ElevatedButton.icon( + onPressed: () async { + final url = Uri.parse('tel:112'); + if (await canLaunchUrl(url)) await launchUrl(url); + }, + icon: const Icon(Icons.local_hospital), + label: const Text('CALL 112 (EMERGENCY)'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.red.shade900, + padding: const EdgeInsets.symmetric(vertical: 20), + ), + ), + ), + ], + + const SizedBox(height: AppSpacing.md), + + // 3. ICE QR + Semantics( + button: true, + label: _showQr ? "Hide ICE QR Code" : "Show ICE QR Code for bystanders to scan", + child: OutlinedButton.icon( + onPressed: () => setState(() => _showQr = !_showQr), + icon: const Icon(Icons.qr_code), + label: Text(_showQr ? 'HIDE EMERGENCY ID' : 'SHOW EMERGENCY ID (QR)'), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + side: const BorderSide(color: Colors.white, width: 2), + padding: const EdgeInsets.symmetric(vertical: 20), + ), + ), + ), + const SizedBox(height: AppSpacing.sm), + // 3b. Full Disability Pass & Medical Certificate + Semantics( + button: true, + label: "Show Official Disability Pass and Medical Certificate to Paramedics or Officials", + child: TextButton.icon( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const DisabilityPassScreen(), + ), + ), + icon: const Icon(Icons.badge, color: Colors.amberAccent), + label: const Text( + 'SHOW OFFICIAL UDID / MEDICAL PASS', + style: TextStyle(color: Colors.amberAccent, fontWeight: FontWeight.bold), + ), + ), + ), + ], + + // Emergency Liability & Assistance Notice + Container( + margin: const EdgeInsets.only(top: AppSpacing.lg), + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: Colors.black38, + borderRadius: AppRadii.borderRadiusMd, + border: Border.all(color: Colors.white24), + ), + child: Row( + children: [ + const Icon(Icons.info_outline, color: Colors.white70, size: 20), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + 'Emergency Aid Notice: This broadcast communicates medical needs for non-verbal or distressed individuals. Bystanders: please render first aid and dial 112 directly.', + style: TextStyle(color: Colors.white.withValues(alpha: 0.8), fontSize: 11), + ), + ), + ], + ), + ), + + const SizedBox(height: AppSpacing.md), + + // 4. Cancel + Semantics( + button: true, + label: "Cancel SOS Emergency and return to map", + child: TextButton( + onPressed: () { + _countdownActive = false; + context.read().ttsService.stop(); + Navigator.of(context).pop(); + }, + style: TextButton.styleFrom( + foregroundColor: Colors.white70, + padding: const EdgeInsets.symmetric(vertical: 20), + ), + child: const Text('CANCEL SOS'), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/map/data/mock_place_repository.dart b/lib/features/map/data/mock_place_repository.dart index ff99783..c68ea4a 100644 --- a/lib/features/map/data/mock_place_repository.dart +++ b/lib/features/map/data/mock_place_repository.dart @@ -24,9 +24,11 @@ class MockPlaceRepository implements PlaceRepository { } @override - Future> getNearbyPlaces(double lat, double lng, {double radiusKm = 10}) async { + Future> getNearbyPlaces(double lat, double lng, {double radiusKm = 50}) async { await Future.delayed(const Duration(milliseconds: 300)); - return List.from(_places); + final sorted = List.from(_places); + sorted.sort((a, b) => a.distanceKmFrom(lat, lng).compareTo(b.distanceKmFrom(lat, lng))); + return sorted.where((p) => p.distanceKmFrom(lat, lng) <= radiusKm).toList(); } @override @@ -110,7 +112,7 @@ class MockPlaceRepository implements PlaceRepository { overallRating: 9, staffInteractionRating: 10, communicationRating: 9, assistanceRating: 10, physicalAccessibilityRating: 9, facilitiesRating: 9, comment: 'Entrance has a proper ramp and staff were very helpful. They guided me to my table and read out the menu. Braille menu was also available. Restroom was accessible too.', createdAt: DateTime.now().subtract(const Duration(days: 5)), - reviewerContext: const AccessibilityContext(travelMode: TravelMode.solo, accessibilityNeeds: [AccessibilityNeed.cannotSee]), + reviewerContext: const AccessibilityContext(travelMode: TravelMode.solo, accessibilityNeeds: [AccessibilityNeed.blindLowVision]), helpfulVotes: 24, ), PlaceReview( @@ -118,7 +120,7 @@ class MockPlaceRepository implements PlaceRepository { overallRating: 9, staffInteractionRating: 9, physicalAccessibilityRating: 10, facilitiesRating: 9, comment: 'I visited with my wheelchair and had no issues at all. The ramp is well-maintained, tables have good clearance, and the accessible parking spot is right near the entrance.', createdAt: DateTime.now().subtract(const Duration(days: 12)), - reviewerContext: const AccessibilityContext(travelMode: TravelMode.solo, accessibilityNeeds: [AccessibilityNeed.wheelchairUser]), + reviewerContext: const AccessibilityContext(travelMode: TravelMode.solo, accessibilityNeeds: [AccessibilityNeed.wheelchairMobility]), helpfulVotes: 18, ), PlaceReview( @@ -134,7 +136,7 @@ class MockPlaceRepository implements PlaceRepository { overallRating: 8, staffInteractionRating: 8, communicationRating: 9, comment: 'Great food and the staff were accommodating. Wish they had tactile guidance from the entrance to the seating area, but staff helped me navigate without any issues.', createdAt: DateTime.now().subtract(const Duration(days: 30)), - reviewerContext: const AccessibilityContext(travelMode: TravelMode.solo, accessibilityNeeds: [AccessibilityNeed.cannotSee]), + reviewerContext: const AccessibilityContext(travelMode: TravelMode.solo, accessibilityNeeds: [AccessibilityNeed.blindLowVision]), helpfulVotes: 9, ), ], @@ -176,7 +178,7 @@ class MockPlaceRepository implements PlaceRepository { overallRating: 9, staffInteractionRating: 9, assistanceRating: 10, physicalAccessibilityRating: 9, comment: 'Excellent accessibility infrastructure. Elevators work well, ramps at every entrance, and hospital staff are trained to assist patients with disabilities.', createdAt: DateTime.now().subtract(const Duration(days: 8)), - reviewerContext: const AccessibilityContext(travelMode: TravelMode.paAssisted, accessibilityNeeds: [AccessibilityNeed.wheelchairUser]), + reviewerContext: const AccessibilityContext(travelMode: TravelMode.paAssisted, accessibilityNeeds: [AccessibilityNeed.wheelchairMobility]), helpfulVotes: 31, ), PlaceReview( @@ -184,7 +186,7 @@ class MockPlaceRepository implements PlaceRepository { overallRating: 8, staffInteractionRating: 8, communicationRating: 7, comment: 'Navigating independently was manageable with tactile guidance on the ground floor, but upper floors lack it. Staff were helpful when asked.', createdAt: DateTime.now().subtract(const Duration(days: 15)), - reviewerContext: const AccessibilityContext(travelMode: TravelMode.solo, accessibilityNeeds: [AccessibilityNeed.cannotSee]), + reviewerContext: const AccessibilityContext(travelMode: TravelMode.solo, accessibilityNeeds: [AccessibilityNeed.blindLowVision]), helpfulVotes: 14, ), PlaceReview( @@ -192,7 +194,7 @@ class MockPlaceRepository implements PlaceRepository { overallRating: 8, communicationRating: 8, assistanceRating: 8, comment: 'Communication was okay — some staff were patient with written communication, while others weren\'t aware of how to help speech-impaired patients.', createdAt: DateTime.now().subtract(const Duration(days: 25)), - reviewerContext: const AccessibilityContext(travelMode: TravelMode.solo, accessibilityNeeds: [AccessibilityNeed.cannotSpeak]), + reviewerContext: const AccessibilityContext(travelMode: TravelMode.solo, accessibilityNeeds: [AccessibilityNeed.speechCommunication]), helpfulVotes: 10, ), ], @@ -230,7 +232,7 @@ class MockPlaceRepository implements PlaceRepository { overallRating: 6, physicalAccessibilityRating: 5, facilitiesRating: 4, comment: 'The main walking path is paved and wheelchair-friendly, but the beach itself is inaccessible. No accessible restroom nearby. Beautiful views though.', createdAt: DateTime.now().subtract(const Duration(days: 10)), - reviewerContext: const AccessibilityContext(travelMode: TravelMode.paAssisted, accessibilityNeeds: [AccessibilityNeed.wheelchairUser]), + reviewerContext: const AccessibilityContext(travelMode: TravelMode.paAssisted, accessibilityNeeds: [AccessibilityNeed.wheelchairMobility]), helpfulVotes: 20, ), PlaceReview( @@ -238,7 +240,7 @@ class MockPlaceRepository implements PlaceRepository { overallRating: 7, staffInteractionRating: 7, communicationRating: 7, comment: 'Nice park for a walk. The paths are mostly flat, but navigating solo with visual impairment requires some caution near the beach area. Benches are well-spaced.', createdAt: DateTime.now().subtract(const Duration(days: 22)), - reviewerContext: const AccessibilityContext(travelMode: TravelMode.solo, accessibilityNeeds: [AccessibilityNeed.cannotSee]), + reviewerContext: const AccessibilityContext(travelMode: TravelMode.solo, accessibilityNeeds: [AccessibilityNeed.blindLowVision]), helpfulVotes: 8, ), ], @@ -280,7 +282,7 @@ class MockPlaceRepository implements PlaceRepository { overallRating: 9, staffInteractionRating: 9, physicalAccessibilityRating: 10, facilitiesRating: 9, comment: 'Excellent mall for wheelchair users! Every floor accessible by elevator, wide aisles, accessible restrooms on each floor. Staff at the info desk were very helpful.', createdAt: DateTime.now().subtract(const Duration(days: 3)), - reviewerContext: const AccessibilityContext(travelMode: TravelMode.solo, accessibilityNeeds: [AccessibilityNeed.wheelchairUser]), + reviewerContext: const AccessibilityContext(travelMode: TravelMode.solo, accessibilityNeeds: [AccessibilityNeed.wheelchairMobility]), helpfulVotes: 15, ), PlaceReview( @@ -288,7 +290,7 @@ class MockPlaceRepository implements PlaceRepository { overallRating: 8, staffInteractionRating: 8, communicationRating: 8, comment: 'Staff were friendly, but there was no tactile guidance inside. High-contrast signage was helpful. Would benefit from audio announcements for visually impaired visitors.', createdAt: DateTime.now().subtract(const Duration(days: 18)), - reviewerContext: const AccessibilityContext(travelMode: TravelMode.solo, accessibilityNeeds: [AccessibilityNeed.cannotSee]), + reviewerContext: const AccessibilityContext(travelMode: TravelMode.solo, accessibilityNeeds: [AccessibilityNeed.blindLowVision]), helpfulVotes: 11, ), PlaceReview( @@ -296,7 +298,7 @@ class MockPlaceRepository implements PlaceRepository { overallRating: 8, communicationRating: 9, assistanceRating: 8, comment: 'Shopping experience was comfortable. Some store staff were accommodating with written communication, though not all stores are equally accessible.', createdAt: DateTime.now().subtract(const Duration(days: 28)), - reviewerContext: const AccessibilityContext(travelMode: TravelMode.paAssisted, accessibilityNeeds: [AccessibilityNeed.cannotSpeak]), + reviewerContext: const AccessibilityContext(travelMode: TravelMode.paAssisted, accessibilityNeeds: [AccessibilityNeed.speechCommunication]), helpfulVotes: 7, ), ], @@ -335,7 +337,7 @@ class MockPlaceRepository implements PlaceRepository { overallRating: 9, staffInteractionRating: 10, assistanceRating: 10, comment: 'The audio guide was fantastic — very detailed descriptions of the architecture and artwork. Staff member walked with me through the entire basilica explaining everything. Truly inclusive experience.', createdAt: DateTime.now().subtract(const Duration(days: 6)), - reviewerContext: const AccessibilityContext(travelMode: TravelMode.solo, accessibilityNeeds: [AccessibilityNeed.cannotSee]), + reviewerContext: const AccessibilityContext(travelMode: TravelMode.solo, accessibilityNeeds: [AccessibilityNeed.blindLowVision]), helpfulVotes: 28, ), PlaceReview( @@ -343,7 +345,7 @@ class MockPlaceRepository implements PlaceRepository { overallRating: 7, physicalAccessibilityRating: 6, facilitiesRating: 5, comment: 'The ramp at the entrance works but it\'s quite steep. Inside, some areas have steps that are challenging with a wheelchair. No accessible restroom on-site.', createdAt: DateTime.now().subtract(const Duration(days: 20)), - reviewerContext: const AccessibilityContext(travelMode: TravelMode.paAssisted, accessibilityNeeds: [AccessibilityNeed.wheelchairUser]), + reviewerContext: const AccessibilityContext(travelMode: TravelMode.paAssisted, accessibilityNeeds: [AccessibilityNeed.wheelchairMobility]), helpfulVotes: 16, ), ], @@ -384,7 +386,7 @@ class MockPlaceRepository implements PlaceRepository { overallRating: 9, staffInteractionRating: 10, assistanceRating: 10, physicalAccessibilityRating: 9, facilitiesRating: 9, comment: 'One of the best accessible hotel experiences in Goa. The accessible room was spacious with grab bars, roll-in shower, and lowered amenities. Staff were incredibly attentive.', createdAt: DateTime.now().subtract(const Duration(days: 4)), - reviewerContext: const AccessibilityContext(travelMode: TravelMode.solo, accessibilityNeeds: [AccessibilityNeed.wheelchairUser]), + reviewerContext: const AccessibilityContext(travelMode: TravelMode.solo, accessibilityNeeds: [AccessibilityNeed.wheelchairMobility]), helpfulVotes: 22, ), PlaceReview( @@ -392,7 +394,7 @@ class MockPlaceRepository implements PlaceRepository { overallRating: 9, staffInteractionRating: 9, communicationRating: 9, comment: 'Staff were amazing. They offered written communication, were patient, and always had a notepad ready. The room had visual alerts for the doorbell and phone.', createdAt: DateTime.now().subtract(const Duration(days: 14)), - reviewerContext: const AccessibilityContext(travelMode: TravelMode.solo, accessibilityNeeds: [AccessibilityNeed.cannotSpeak]), + reviewerContext: const AccessibilityContext(travelMode: TravelMode.solo, accessibilityNeeds: [AccessibilityNeed.speechCommunication]), helpfulVotes: 15, ), ], @@ -429,7 +431,7 @@ class MockPlaceRepository implements PlaceRepository { overallRating: 7, staffInteractionRating: 9, communicationRating: 9, physicalAccessibilityRating: 3, comment: 'Staff are lovely and very patient, but the entrance has two steps and the garden paths are uneven gravel — very difficult with a wheelchair. Great food though!', createdAt: DateTime.now().subtract(const Duration(days: 15)), - reviewerContext: const AccessibilityContext(travelMode: TravelMode.paAssisted, accessibilityNeeds: [AccessibilityNeed.wheelchairUser]), + reviewerContext: const AccessibilityContext(travelMode: TravelMode.paAssisted, accessibilityNeeds: [AccessibilityNeed.wheelchairMobility]), helpfulVotes: 18, ), PlaceReview( @@ -437,7 +439,7 @@ class MockPlaceRepository implements PlaceRepository { overallRating: 8, staffInteractionRating: 9, communicationRating: 8, comment: 'Nice atmosphere. Staff helped me find a table and described the menu items. The garden setting is peaceful but navigation requires assistance.', createdAt: DateTime.now().subtract(const Duration(days: 30)), - reviewerContext: const AccessibilityContext(travelMode: TravelMode.paAssisted, accessibilityNeeds: [AccessibilityNeed.cannotSee]), + reviewerContext: const AccessibilityContext(travelMode: TravelMode.paAssisted, accessibilityNeeds: [AccessibilityNeed.blindLowVision]), helpfulVotes: 6, ), ], @@ -474,7 +476,7 @@ class MockPlaceRepository implements PlaceRepository { overallRating: 6, staffInteractionRating: 5, assistanceRating: 6, comment: 'The building has a ramp and elevator which is good. However, staff at the counters were not very patient or aware of how to assist people with disabilities. Long waiting times.', createdAt: DateTime.now().subtract(const Duration(days: 25)), - reviewerContext: const AccessibilityContext(travelMode: TravelMode.solo, accessibilityNeeds: [AccessibilityNeed.cannotSpeak]), + reviewerContext: const AccessibilityContext(travelMode: TravelMode.solo, accessibilityNeeds: [AccessibilityNeed.speechCommunication]), helpfulVotes: 12, ), ], @@ -512,7 +514,7 @@ class MockPlaceRepository implements PlaceRepository { overallRating: 7, physicalAccessibilityRating: 6, facilitiesRating: 6, comment: 'The newer buildings have good accessibility, but the older departments are still challenging. Library has ramp access and the campus is mostly flat which helps.', createdAt: DateTime.now().subtract(const Duration(days: 12)), - reviewerContext: const AccessibilityContext(travelMode: TravelMode.solo, accessibilityNeeds: [AccessibilityNeed.wheelchairUser]), + reviewerContext: const AccessibilityContext(travelMode: TravelMode.solo, accessibilityNeeds: [AccessibilityNeed.wheelchairMobility]), helpfulVotes: 8, ), ], @@ -549,7 +551,7 @@ class MockPlaceRepository implements PlaceRepository { overallRating: 10, staffInteractionRating: 10, assistanceRating: 10, comment: 'Absolute lifesaver! Fixed my wheelchair within an hour. Staff understood exactly what I needed and the shop is fully accessible. They even have loaner wheelchairs.', createdAt: DateTime.now().subtract(const Duration(days: 7)), - reviewerContext: const AccessibilityContext(travelMode: TravelMode.solo, accessibilityNeeds: [AccessibilityNeed.wheelchairUser]), + reviewerContext: const AccessibilityContext(travelMode: TravelMode.solo, accessibilityNeeds: [AccessibilityNeed.wheelchairMobility]), helpfulVotes: 25, ), ], @@ -586,7 +588,7 @@ class MockPlaceRepository implements PlaceRepository { overallRating: 9, staffInteractionRating: 9, communicationRating: 9, assistanceRating: 9, comment: 'Wonderful community space. They have sign language interpreters at most events and staff are trained in basic accessibility awareness. Truly inclusive.', createdAt: DateTime.now().subtract(const Duration(days: 8)), - reviewerContext: const AccessibilityContext(travelMode: TravelMode.solo, accessibilityNeeds: [AccessibilityNeed.deaf]), + reviewerContext: const AccessibilityContext(travelMode: TravelMode.solo, accessibilityNeeds: [AccessibilityNeed.deafHardOfHearing]), helpfulVotes: 16, ), PlaceReview( @@ -594,7 +596,7 @@ class MockPlaceRepository implements PlaceRepository { overallRating: 8, staffInteractionRating: 8, communicationRating: 8, comment: 'Good accessibility overall. The center hosts regular disability awareness events. Staff are patient and willing to accommodate different communication needs.', createdAt: DateTime.now().subtract(const Duration(days: 20)), - reviewerContext: const AccessibilityContext(travelMode: TravelMode.solo, accessibilityNeeds: [AccessibilityNeed.cannotTalk]), + reviewerContext: const AccessibilityContext(travelMode: TravelMode.solo, accessibilityNeeds: [AccessibilityNeed.speechCommunication]), helpfulVotes: 10, ), ], @@ -635,7 +637,7 @@ class MockPlaceRepository implements PlaceRepository { overallRating: 9, staffInteractionRating: 10, assistanceRating: 10, physicalAccessibilityRating: 9, comment: 'Excellent hospital with top-notch accessibility. Every floor is wheelchair accessible, staff are trained, and they even have dedicated assistance for patients with disabilities.', createdAt: DateTime.now().subtract(const Duration(days: 3)), - reviewerContext: const AccessibilityContext(travelMode: TravelMode.paAssisted, accessibilityNeeds: [AccessibilityNeed.physicallyDisabled]), + reviewerContext: const AccessibilityContext(travelMode: TravelMode.paAssisted, accessibilityNeeds: [AccessibilityNeed.wheelchairMobility]), helpfulVotes: 20, ), PlaceReview( @@ -643,11 +645,78 @@ class MockPlaceRepository implements PlaceRepository { overallRating: 9, staffInteractionRating: 9, communicationRating: 9, comment: 'The tactile guidance throughout the hospital is excellent. Reception staff are well-trained and patient. Audio announcements help with navigation.', createdAt: DateTime.now().subtract(const Duration(days: 10)), - reviewerContext: const AccessibilityContext(travelMode: TravelMode.solo, accessibilityNeeds: [AccessibilityNeed.cannotSee]), + reviewerContext: const AccessibilityContext(travelMode: TravelMode.solo, accessibilityNeeds: [AccessibilityNeed.blindLowVision]), helpfulVotes: 14, ), ], ), + Place( + id: 'place-13', + name: 'Panaji Bus Stand', + category: PlaceCategory.busStop, + address: 'Patto Centre, Panaji, Goa 403001', + directionsUrl: 'https://www.google.com/maps/search/?api=1&query=Panaji+Bus+Stand+Goa', + description: 'Main bus terminal connecting Panaji to all parts of Goa.', + latitude: 15.4980, + longitude: 73.8375, + friendlyScore: 7.0, + wheelchairScore: 6.5, + visualAccessibilityScore: 6.0, + hearingAccessibilityScore: 7.5, + communicationScore: 7.0, + totalReviews: 40, + communityConfirmations: 25, + lastVerified: DateTime.now().subtract(const Duration(days: 5)), + accessibilityFeatures: [ + AccessibilityFeature(id: 'f74', name: 'Tactile paving', icon: Icons.touch_app, category: AccessibilityCategory.visual, status: FeatureStatus.available), + ], + reviews: [], + ), + Place( + id: 'place-14', + name: 'Apollo Pharmacy', + category: PlaceCategory.pharmacy, + address: 'MG Road, Panaji, Goa 403001', + directionsUrl: 'https://www.google.com/maps/search/?api=1&query=Apollo+Pharmacy+Panaji+Goa', + description: '24/7 Pharmacy with wheelchair accessible ramp.', + latitude: 15.4950, + longitude: 73.8250, + friendlyScore: 8.5, + wheelchairScore: 9.0, + visualAccessibilityScore: 8.0, + hearingAccessibilityScore: 8.0, + communicationScore: 8.5, + totalReviews: 12, + communityConfirmations: 8, + lastVerified: DateTime.now().subtract(const Duration(days: 2)), + accessibilityFeatures: [ + AccessibilityFeature(id: 'f75', name: 'Ramp', icon: Icons.accessible, category: AccessibilityCategory.physical, status: FeatureStatus.available), + ], + reviews: [], + ), + Place( + id: 'place-15', + name: 'HDFC Bank ATM', + category: PlaceCategory.atm, + address: '18th June Road, Panaji, Goa 403001', + directionsUrl: 'https://www.google.com/maps/search/?api=1&query=HDFC+Bank+ATM+18th+June+Road+Panaji', + description: 'Voice-enabled ATM with braille keys.', + latitude: 15.4965, + longitude: 73.8220, + friendlyScore: 9.0, + wheelchairScore: 8.5, + visualAccessibilityScore: 9.5, + hearingAccessibilityScore: 9.0, + communicationScore: 9.0, + totalReviews: 15, + communityConfirmations: 10, + lastVerified: DateTime.now().subtract(const Duration(days: 1)), + accessibilityFeatures: [ + AccessibilityFeature(id: 'f76', name: 'Braille keypad', icon: Icons.touch_app, category: AccessibilityCategory.visual, status: FeatureStatus.available), + AccessibilityFeature(id: 'f77', name: 'Audio jack', icon: Icons.headphones, category: AccessibilityCategory.visual, status: FeatureStatus.available), + ], + reviews: [], + ), ]; } } diff --git a/lib/features/map/presentation/discover_screen.dart b/lib/features/map/presentation/discover_screen.dart index bdc2bf6..0b204ca 100644 --- a/lib/features/map/presentation/discover_screen.dart +++ b/lib/features/map/presentation/discover_screen.dart @@ -38,6 +38,7 @@ class _DiscoverScreenState extends State { child: RefreshIndicator( onRefresh: context.read().loadPlaces, child: ListView( + key: const ValueKey('discover-list'), padding: const EdgeInsets.all(AppSpacing.lg), children: [ const AccessSearchBar(), diff --git a/lib/features/map/presentation/exploration_overlay.dart b/lib/features/map/presentation/exploration_overlay.dart new file mode 100644 index 0000000..c5a9952 --- /dev/null +++ b/lib/features/map/presentation/exploration_overlay.dart @@ -0,0 +1,1015 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:access_map/app/app_state.dart'; +import 'package:access_map/core/services/exploration_service.dart'; +import 'package:access_map/shared/models/place.dart'; +import 'package:access_map/shared/models/place_category.dart'; +import 'package:access_map/core/theme/app_theme.dart'; +import 'package:access_map/features/places/presentation/place_details_screen.dart'; +import 'package:access_map/shared/models/hazard_report.dart'; +import 'package:access_map/shared/models/accessibility_need.dart'; + +class ExplorationOverlay extends StatelessWidget { + const ExplorationOverlay({super.key}); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, state, child) { + final exploration = state.explorationService; + + if (!exploration.isActive) return const SizedBox.shrink(); + + final isNavigating = exploration.activeDestination != null; + + return Positioned.fill( + child: Container( + color: isNavigating ? Colors.transparent : Colors.black.withValues(alpha: 0.5), + child: SafeArea( + child: isNavigating + ? _buildGoogleMapsNavigationHUD(context, exploration, state) + : _buildLazarilloCategoryExplorer(context, exploration, state), + ), + ), + ); + }, + ); + } + + // ========================================== + // 1. LAZARILLO-STYLE CATEGORY EXPLORER VIEW + // ========================================== + Widget _buildLazarilloCategoryExplorer( + BuildContext context, + ExplorationService exploration, + AppState state, + ) { + final categories = [ + _ExplorerCategory( + label: 'Hospitals & Medical', + icon: Icons.local_hospital, + color: const Color(0xFFDC2626), + filter: (p) => + p.category == PlaceCategory.hospital || + p.category == PlaceCategory.clinic || + p.category == PlaceCategory.pharmacy, + ), + _ExplorerCategory( + label: 'Bus Stands & Transit', + icon: Icons.directions_bus, + color: const Color(0xFF2563EB), + filter: (p) => p.category == PlaceCategory.busStop, + ), + _ExplorerCategory( + label: 'Schools & Colleges', + icon: Icons.school, + color: const Color(0xFF0D9488), + filter: (p) => p.category == PlaceCategory.college, + ), + _ExplorerCategory( + label: 'Clubs & Entertainment', + icon: Icons.nightlife, + color: const Color(0xFF9333EA), + filter: (p) => + p.category == PlaceCategory.touristAttraction || + p.category == PlaceCategory.hotel, + ), + _ExplorerCategory( + label: 'Restaurants & Cafes', + icon: Icons.restaurant, + color: const Color(0xFFEA580C), + filter: (p) => + p.category == PlaceCategory.restaurant || + p.category == PlaceCategory.cafe, + ), + _ExplorerCategory( + label: 'Banks & ATMs', + icon: Icons.local_atm, + color: const Color(0xFF16A34A), + filter: (p) => p.category == PlaceCategory.atm, + ), + ]; + + return Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Top Header: Facing direction & Close button + Row( + children: [ + Expanded( + child: Semantics( + label: 'Facing direction', + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: AppRadii.borderRadiusMd, + border: Border.all(color: AppColors.border, width: 2), + ), + child: Row( + children: [ + const Icon(Icons.explore, color: AppColors.primary), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + exploration.currentHeading != null + ? 'Facing ${exploration.currentHeading!.round()}°' + : (state.profile.accessibilityNeeds.contains(AccessibilityNeed.blindLowVision) + ? 'Audible Navigator' + : 'Accessible Navigator'), + style: AppTypography.titleMedium.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ), + ), + ), + const SizedBox(width: AppSpacing.sm), + Container( + decoration: BoxDecoration( + color: AppColors.errorLight, + borderRadius: AppRadii.borderRadiusMd, + border: Border.all(color: AppColors.error, width: 2), + ), + child: IconButton( + icon: const Icon(Icons.close, color: AppColors.error, size: 28), + onPressed: () => exploration.stopExploration(), + tooltip: 'Exit Navigator', + ), + ), + ], + ), + const SizedBox(height: AppSpacing.md), + + // Big "Where Am I?" button (High contrast, 1-tap landmark announcement) + Semantics( + button: true, + label: 'Where am I? Double tap to announce your current area and landmark', + child: ElevatedButton.icon( + onPressed: () async { + final loc = await exploration.whereAmI(); + if (context.mounted) { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + backgroundColor: const Color(0xFF0F172A), + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: const BorderSide(color: Colors.amberAccent, width: 1.5), + ), + content: Row( + children: [ + const Icon(Icons.my_location, color: Colors.amberAccent, size: 22), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + loc, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + ), + ], + ), + duration: const Duration(seconds: 4), + ), + ); + } + }, + icon: const Icon(Icons.my_location, size: 30), + label: const Text('Where Am I? (Spoken Landmark)'), + style: ElevatedButton.styleFrom( + minimumSize: const Size.fromHeight(64), + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + textStyle: AppTypography.headlineSmall.copyWith( + fontWeight: FontWeight.bold, + fontSize: 18, + ), + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: AppRadii.borderRadiusLg, + ), + ), + ), + ), + const SizedBox(height: AppSpacing.sm), + + // Report Hazard Button (Earn 15 pts) + ElevatedButton.icon( + onPressed: () => _showReportHazardSheet(context, exploration), + icon: const Icon(Icons.warning_amber_rounded, color: Colors.white, size: 20), + label: const Text('Report Hazard at My Location (+15 pts)'), + style: ElevatedButton.styleFrom( + minimumSize: const Size.fromHeight(46), + backgroundColor: const Color(0xFFD97706), + foregroundColor: Colors.white, + textStyle: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13), + shape: RoundedRectangleBorder(borderRadius: AppRadii.borderRadiusMd), + ), + ), + const SizedBox(height: AppSpacing.md), + + // Title + Container( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs), + child: Text( + 'Explore Nearby (Choose a Category)', + style: AppTypography.titleLarge.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + shadows: [ + const Shadow(color: Colors.black, blurRadius: 4), + ], + ), + ), + ), + const SizedBox(height: AppSpacing.sm), + + // Lazarillo Grid: 6 Big Accessible Category Cards + Expanded( + child: GridView.builder( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: AppSpacing.md, + mainAxisSpacing: AppSpacing.md, + childAspectRatio: 0.88, + ), + itemCount: categories.length, + itemBuilder: (context, index) { + final cat = categories[index]; + final matchingCount = state.places.where(cat.filter).length; + + return Semantics( + button: true, + label: '${cat.label}. $matchingCount nearby places. Double tap to view.', + child: InkWell( + onTap: () => _showCategoryPlacesSheet( + context, + cat, + state, + exploration, + ), + borderRadius: AppRadii.borderRadiusLg, + child: Container( + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: AppRadii.borderRadiusLg, + border: Border.all(color: cat.color, width: 2.5), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.25), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: AppSpacing.md, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircleAvatar( + radius: 24, + backgroundColor: cat.color.withValues(alpha: 0.15), + foregroundColor: cat.color, + child: Icon(cat.icon, size: 28), + ), + const SizedBox(height: AppSpacing.xs), + Text( + cat.label, + textAlign: TextAlign.center, + style: AppTypography.titleMedium.copyWith( + fontWeight: FontWeight.bold, + fontSize: 14, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + '$matchingCount nearby', + style: AppTypography.bodySmall.copyWith( + color: AppColors.textSecondary, + fontWeight: FontWeight.w600, + fontSize: 11, + ), + ), + ], + ), + ), + ), + ); + }, + ), + ), + ], + ), + ); + } + + // ========================================== + // 2. GOOGLE MAPS-STYLE TURN-BY-TURN HUD + // ========================================== + Widget _buildGoogleMapsNavigationHUD( + BuildContext context, + ExplorationService exploration, + AppState state, + ) { + final dest = exploration.activeDestination!; + final distance = (exploration.distanceToDestination ?? 0).round(); + final walkTime = exploration.estimatedWalkingMinutes; + final nearbyHazard = exploration.nearbyHazard; + final isBlind = state.profile.accessibilityNeeds.contains(AccessibilityNeed.blindLowVision); + + return Stack( + children: [ + // 1. TOP FLOATING GOOGLE MAPS TURN BANNER & HAZARD BANNER + Positioned( + top: AppSpacing.sm, + left: AppSpacing.md, + right: AppSpacing.md, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md, vertical: AppSpacing.sm), + decoration: BoxDecoration( + color: const Color(0xFF0F766E), // Emerald navigation green + borderRadius: AppRadii.borderRadiusLg, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.35), + blurRadius: 14, + offset: const Offset(0, 5), + ), + ], + ), + child: Row( + children: [ + const CircleAvatar( + radius: 20, + backgroundColor: Colors.white, + foregroundColor: Color(0xFF0F766E), + child: Icon(Icons.navigation, size: 24), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + exploration.navigationInstruction, + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 2), + Text( + exploration.nextTurnHint, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.9), + fontSize: 12, + ), + ), + ], + ), + ), + ], + ), + ), + if (nearbyHazard != null) ...[ + const SizedBox(height: AppSpacing.xs), + _buildHazardProximityBanner(context, exploration, nearbyHazard), + ], + ], + ), + ), + + // 2. BOTTOM FLOATING NAVIGATION CARD + Positioned( + left: AppSpacing.md, + right: AppSpacing.md, + bottom: AppSpacing.md, + child: Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: AppRadii.borderRadiusLg, + border: Border.all(color: AppColors.border, width: 2), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 16, + offset: const Offset(0, 6), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + dest.name, + style: AppTypography.titleLarge.copyWith(fontWeight: FontWeight.bold), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: AppColors.successLight, + borderRadius: AppRadii.borderRadiusSm, + ), + child: Text( + dest.wheelchairScore >= 8.5 ? 'Step-Free' : 'Accessible', + style: const TextStyle( + color: AppColors.success, + fontWeight: FontWeight.bold, + fontSize: 11, + ), + ), + ), + ], + ), + const SizedBox(height: 6), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _NavMetric( + label: 'Distance Remaining', + value: '$distance m', + icon: Icons.straighten, + ), + _NavMetric( + label: 'Est. Walk Time', + value: '$walkTime min', + icon: Icons.timer_outlined, + ), + ], + ), + const SizedBox(height: AppSpacing.sm), + // 1-Tap In-Route Hazard Report Trigger + ElevatedButton.icon( + onPressed: () => _showReportHazardSheet(context, exploration), + icon: const Icon(Icons.warning_amber_rounded, color: Colors.white, size: 18), + label: const Text('Report Hazard at My Location (+15 pts)'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFD97706), // Amber warning + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 9), + textStyle: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12), + ), + ), + const SizedBox(height: AppSpacing.xs), + Row( + children: [ + if (isBlind) ...[ + Expanded( + child: ElevatedButton.icon( + onPressed: () => exploration.repeatDirections(), + icon: const Icon(Icons.volume_up, size: 18), + label: const Text('Repeat Voice', maxLines: 1, overflow: TextOverflow.ellipsis), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 10), + textStyle: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12), + ), + ), + ), + const SizedBox(width: AppSpacing.sm), + ], + Expanded( + child: OutlinedButton.icon( + onPressed: () => exploration.clearDestination(), + icon: const Icon(Icons.close, color: AppColors.error, size: 18), + label: const Text('End Navigation', maxLines: 1, overflow: TextOverflow.ellipsis), + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.error, + side: const BorderSide(color: AppColors.error, width: 1.5), + padding: const EdgeInsets.symmetric(vertical: 10), + textStyle: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12), + ), + ), + ), + ], + ), + ], + ), + ), + ), + ], + ); + } + + /// Live Hazard Proximity Alert Banner (Amber/Red Card with 1-tap verification) + Widget _buildHazardProximityBanner( + BuildContext context, + ExplorationService exploration, + HazardReport hazard, + ) { + final distance = (exploration.distanceToLocation(hazard.latitude, hazard.longitude)).round(); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md, vertical: AppSpacing.sm), + decoration: BoxDecoration( + color: const Color(0xFFFFFBEB), // Soft amber + borderRadius: AppRadii.borderRadiusMd, + border: Border.all(color: const Color(0xFFF59E0B), width: 2), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + CircleAvatar( + radius: 15, + backgroundColor: hazard.category.color.withValues(alpha: 0.2), + foregroundColor: hazard.category.color, + child: Icon(hazard.category.icon, size: 18), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'CAUTION: ${hazard.category.label} (${distance}m ahead)', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 13, + color: hazard.category.color, + ), + ), + Text( + '${hazard.description} • ${hazard.ageLabel}', + style: const TextStyle( + fontSize: 11, + color: Color(0xFF78350F), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + const SizedBox(height: 6), + Wrap( + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 6, + runSpacing: 6, + children: [ + Text( + 'Is this still blocked?', + style: TextStyle(fontSize: 11, color: Colors.brown.shade700, fontWeight: FontWeight.w600), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + FilledButton.tonal( + style: FilledButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: const Color(0xFFB45309), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + visualDensity: VisualDensity.compact, + ), + onPressed: () => exploration.verifyHazardStillBlocked(hazard.id), + child: const Text('Still Blocked (+5 pts)', style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold)), + ), + const SizedBox(width: 6), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: const Color(0xFF059669), // emerald + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + visualDensity: VisualDensity.compact, + ), + onPressed: () => exploration.markHazardFixed(hazard.id), + child: const Text('Fixed! (+10 pts)', style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold)), + ), + ], + ), + ], + ), + ], + ), + ); + } + + /// 1-Tap In-Route Hazard Reporting Modal Bottom Sheet + void _showReportHazardSheet(BuildContext context, ExplorationService exploration) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (ctx) => SafeArea( + child: Container( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(ctx).size.height * 0.85, + ), + padding: const EdgeInsets.all(AppSpacing.lg), + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: AppSpacing.md), + Row( + children: [ + const Icon(Icons.add_location_alt, color: Color(0xFFD97706), size: 28), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Report Hazard at My Location', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 17), + ), + Text( + 'Live GPS: ${exploration.currentEffectiveLat.toStringAsFixed(4)}, ${exploration.currentEffectiveLng.toStringAsFixed(4)} • Earn +15 pts', + style: TextStyle(color: Colors.grey.shade600, fontSize: 12), + ), + ], + ), + ), + ], + ), + const SizedBox(height: AppSpacing.md), + const Text( + 'Select obstacle category to warn nearby travellers:', + style: TextStyle(fontWeight: FontWeight.w600, fontSize: 13), + ), + const SizedBox(height: AppSpacing.sm), + ...[ + HazardCategory.rampBlocked, + HazardCategory.liftBroken, + HazardCategory.unexpectedStairs, + HazardCategory.sidewalkBlocked, + HazardCategory.unsafeCrossing, + HazardCategory.constructionObstruction, + HazardCategory.waterlogging, + ].map( + (cat) => Card( + elevation: 0, + color: cat.color.withValues(alpha: 0.08), + margin: const EdgeInsets.only(bottom: 8), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: cat.color.withValues(alpha: 0.3)), + ), + child: ListTile( + dense: true, + leading: CircleAvatar( + radius: 16, + backgroundColor: cat.color.withValues(alpha: 0.2), + foregroundColor: cat.color, + child: Icon(cat.icon, size: 18), + ), + title: Text(cat.label, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), + trailing: const Icon(Icons.arrow_forward_ios, size: 13), + onTap: () async { + Navigator.of(ctx).pop(); + await exploration.addHazardReport(cat); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + backgroundColor: const Color(0xFF0F172A), + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: const BorderSide(color: Colors.amberAccent, width: 1.5), + ), + content: Row( + children: [ + const Icon(Icons.stars, color: Colors.amberAccent), + const SizedBox(width: 8), + Expanded( + child: Text('Reported "${cat.label}" at your location! +15 impact points earned.'), + ), + ], + ), + ), + ); + } + }, + ), + ), + ), + const SizedBox(height: AppSpacing.sm), + ], + ), + ), + ), + ), + ); + } + + // ========================================== + // 3. CATEGORY PLACES BOTTOM SHEET + // ========================================== + void _showCategoryPlacesSheet( + BuildContext context, + _ExplorerCategory category, + AppState state, + ExplorationService exploration, + ) { + final userLat = exploration.currentEffectiveLat; + final userLng = exploration.currentEffectiveLng; + + // Filter places and sort by distance + final matchingPlaces = state.places.where(category.filter).toList(); + matchingPlaces.sort( + (a, b) => a + .distanceKmFrom(userLat, userLng) + .compareTo(b.distanceKmFrom(userLat, userLng)), + ); + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (ctx) { + return Container( + height: MediaQuery.of(context).size.height * 0.75, + decoration: const BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + child: Column( + children: [ + // Drag handle & Header + const SizedBox(height: 12), + Container( + width: 44, + height: 5, + decoration: BoxDecoration( + color: AppColors.divider, + borderRadius: AppRadii.borderRadiusFull, + ), + ), + Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Row( + children: [ + CircleAvatar( + backgroundColor: category.color.withValues(alpha: 0.15), + foregroundColor: category.color, + child: Icon(category.icon), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + category.label, + style: AppTypography.headlineSmall.copyWith( + fontWeight: FontWeight.bold, + ), + ), + Text( + '${matchingPlaces.length} places near you (Sorted by distance)', + style: AppTypography.bodySmall, + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(ctx).pop(), + ), + ], + ), + ), + const Divider(height: 1), + + // Places list + Expanded( + child: matchingPlaces.isEmpty + ? Center( + child: Text( + 'No venues found nearby in this category.', + style: AppTypography.bodyLarge, + ), + ) + : ListView.separated( + padding: const EdgeInsets.all(AppSpacing.md), + itemCount: matchingPlaces.length, + separatorBuilder: (_, __) => const SizedBox(height: AppSpacing.sm), + itemBuilder: (context, idx) { + final place = matchingPlaces[idx]; + final distKm = place.distanceKmFrom(userLat, userLng); + final distMeters = (distKm * 1000).round(); + + return Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: AppRadii.borderRadiusMd, + side: const BorderSide(color: AppColors.divider), + ), + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + place.name, + style: AppTypography.titleLarge.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: AppColors.primarySurface, + borderRadius: AppRadii.borderRadiusSm, + ), + child: Text( + '$distMeters m away', + style: const TextStyle( + color: AppColors.primary, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + place.address, + style: AppTypography.bodySmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 8), + // Accessibility feature tag + Row( + children: [ + const Icon(Icons.check_circle, size: 16, color: AppColors.success), + const SizedBox(width: 4), + Expanded( + child: Text( + place.accessibilityFeatures + .take(2) + .map((f) => f.name) + .join(' • '), + style: AppTypography.bodySmall.copyWith( + color: AppColors.textSecondary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: AppSpacing.md), + // Action Buttons: "Start Navigation" & "Details" + Row( + children: [ + Expanded( + flex: 2, + child: ElevatedButton.icon( + onPressed: () { + Navigator.of(ctx).pop(); + exploration.setDestination(place); + }, + icon: const Icon(Icons.directions_walk), + label: const Text('Start Navigation'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF0F766E), + foregroundColor: Colors.white, + textStyle: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: OutlinedButton( + onPressed: () { + Navigator.of(ctx).pop(); + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => PlaceDetailsScreen(placeId: place.id), + ), + ); + }, + child: const Text('Details'), + ), + ), + ], + ), + ], + ), + ), + ); + }, + ), + ), + ], + ), + ); + }, + ); + } +} + +class _ExplorerCategory { + final String label; + final IconData icon; + final Color color; + final bool Function(Place) filter; + + _ExplorerCategory({ + required this.label, + required this.icon, + required this.color, + required this.filter, + }); +} + +class _NavMetric extends StatelessWidget { + const _NavMetric({ + required this.label, + required this.value, + required this.icon, + }); + + final String label; + final String value; + final IconData icon; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Icon(icon, color: AppColors.primary, size: 22), + const SizedBox(height: 2), + Text( + value, + style: AppTypography.titleLarge.copyWith(fontWeight: FontWeight.bold), + ), + Text( + label, + style: AppTypography.bodySmall.copyWith(color: AppColors.textSecondary), + ), + ], + ); + } +} diff --git a/lib/features/map/presentation/main_shell.dart b/lib/features/map/presentation/main_shell.dart index 974c811..9945117 100644 --- a/lib/features/map/presentation/main_shell.dart +++ b/lib/features/map/presentation/main_shell.dart @@ -26,7 +26,7 @@ class MainShell extends StatelessWidget { destinations: const [ NavigationDestination(icon: Icon(Icons.map_outlined), label: 'Map'), NavigationDestination(icon: Icon(Icons.explore_outlined), label: 'Discover'), - NavigationDestination(icon: Icon(Icons.volunteer_activism_outlined), label: 'Contribute'), + NavigationDestination(icon: Icon(Icons.people_alt_outlined), label: 'Community'), NavigationDestination(icon: Icon(Icons.person_outline), label: 'Profile'), ], ), diff --git a/lib/features/map/presentation/map_screen.dart b/lib/features/map/presentation/map_screen.dart index 22bbd5b..e5292bb 100644 --- a/lib/features/map/presentation/map_screen.dart +++ b/lib/features/map/presentation/map_screen.dart @@ -3,14 +3,21 @@ import 'dart:math' as math; import 'package:access_map/app/app_state.dart'; import 'package:access_map/core/theme/app_theme.dart'; import 'package:access_map/features/places/presentation/place_details_screen.dart'; +import 'package:access_map/features/emergency/presentation/sos_emergency_screen.dart'; +import 'package:access_map/features/map/presentation/exploration_overlay.dart'; import 'package:access_map/shared/models/place.dart'; import 'package:access_map/shared/models/place_category.dart'; import 'package:access_map/shared/widgets/access_search_bar.dart'; import 'package:access_map/shared/widgets/app_components.dart'; +import 'package:access_map/shared/models/accessibility_need.dart'; +import 'package:access_map/shared/models/travel_mode.dart'; +import 'package:access_map/shared/models/hazard_report.dart'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; import 'package:provider/provider.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:access_map/shared/models/map_style.dart'; class MapScreen extends StatefulWidget { const MapScreen({super.key}); @@ -22,11 +29,122 @@ class MapScreen extends StatefulWidget { class _MapScreenState extends State { PlaceCategory? _categoryFilter; bool _highlyFriendlyOnly = false; + late final MapController _mapController = MapController(); + bool _isLocating = false; + MapStyle _currentMapStyle = MapStyle.standard; + bool _is3DMode = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _fetchInitialLocation(); + }); + } + + Future _fetchInitialLocation() async { + final pos = await context.read().explorationService.getCurrentLocation(requestPermission: false); + if (pos != null && mounted) { + _mapController.move(LatLng(pos.latitude, pos.longitude), 15.0); + } + } + + Future _onMyLocationPressed() async { + if (_isLocating) return; + setState(() => _isLocating = true); + + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Row( + children: [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ), + SizedBox(width: 12), + Text('Locating GPS position...'), + ], + ), + duration: Duration(seconds: 2), + ), + ); + + final exploration = context.read().explorationService; + final pos = await exploration.getCurrentLocation(requestPermission: true); + + if (!mounted) return; + setState(() => _isLocating = false); + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + + if (pos != null) { + _mapController.move(LatLng(pos.latitude, pos.longitude), 16.0); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Centered on your location (${pos.latitude.toStringAsFixed(4)}, ${pos.longitude.toStringAsFixed(4)})', + ), + duration: const Duration(seconds: 2), + ), + ); + } else { + final serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!mounted) return; + if (!serviceEnabled) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Location services are turned off on device.'), + action: SnackBarAction( + label: 'Settings', + onPressed: () => Geolocator.openLocationSettings(), + ), + duration: const Duration(seconds: 4), + ), + ); + } else { + final permission = await Geolocator.checkPermission(); + if (!mounted) return; + if (permission == LocationPermission.deniedForever) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Location permission permanently denied.'), + action: SnackBarAction( + label: 'Settings', + onPressed: () => Geolocator.openAppSettings(), + ), + duration: const Duration(seconds: 4), + ), + ); + } else { + _mapController.move( + LatLng(exploration.currentEffectiveLat, exploration.currentEffectiveLng), + 15.0, + ); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('GPS signal weak. Centered on best available position.'), + duration: Duration(seconds: 2), + ), + ); + } + } + } + } + + @override + void dispose() { + _mapController.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { final state = context.watch(); - final places = _filteredPlaces(state.visiblePlaces); + final places = _filteredPlaces(state.visiblePlaces, state.profile.travelMode); + final exploration = state.explorationService; + final isNavigating = exploration.activeDestination != null; + return SafeArea( child: Stack( children: [ @@ -34,52 +152,140 @@ class _MapScreenState extends State { child: state.isLoadingPlaces ? const Center(child: CircularProgressIndicator()) : _AccessibleMap( + mapController: _mapController, + mapStyle: _currentMapStyle, + is3DMode: _is3DMode, places: places, selectedPlace: state.selectedPlace, + activeDestination: exploration.activeDestination, + routePoints: exploration.routePolylinePoints, + hazards: exploration.activeHazards, + userLocation: LatLng( + exploration.currentEffectiveLat, + exploration.currentEffectiveLng, + ), ), ), - Positioned( - top: AppSpacing.lg, - left: AppSpacing.lg, - right: AppSpacing.lg, - child: Column( - children: [ - const AccessSearchBar(), - if (state.searchQuery.isNotEmpty || state.isSearching) - _SearchResults(places: places), - ], + if (!isNavigating) ...[ + Positioned( + top: AppSpacing.lg, + left: AppSpacing.lg, + right: AppSpacing.lg, + child: Column( + children: [ + const AccessSearchBar(), + const SizedBox(height: AppSpacing.sm), + _ModeIndicator(mode: state.profile.travelMode), + if (state.searchQuery.isNotEmpty || state.isSearching) + _SearchResults(places: places), + ], + ), ), - ), + Positioned( + top: 92, + right: AppSpacing.lg, + child: FloatingActionButton.small( + heroTag: 'filters', + tooltip: 'Filters', + backgroundColor: AppColors.surface, + foregroundColor: AppColors.primary, + onPressed: () => _showFilters(context), + child: const Icon(Icons.tune), + ), + ), + Positioned( + top: 142, + right: AppSpacing.lg, + child: FloatingActionButton.small( + heroTag: 'map_layers', + tooltip: 'Map Layers & 3D Options', + backgroundColor: _is3DMode || _currentMapStyle != MapStyle.standard + ? AppColors.primary + : AppColors.surface, + foregroundColor: _is3DMode || _currentMapStyle != MapStyle.standard + ? Colors.white + : AppColors.primary, + onPressed: () => _showMapStyleSelector(context), + child: Icon(_is3DMode ? Icons.view_in_ar : Icons.layers), + ), + ), + if (_is3DMode) + Positioned( + top: 146, + right: AppSpacing.lg + 48, + child: GestureDetector( + onTap: () { + setState(() { + _is3DMode = false; + _mapController.rotate(0.0); + }); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: AppColors.primaryDark, + borderRadius: BorderRadius.circular(12), + boxShadow: const [ + BoxShadow(color: Colors.black26, blurRadius: 4, offset: Offset(0, 2)), + ], + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.threed_rotation, color: Colors.white, size: 14), + SizedBox(width: 4), + Text('3D ON', style: TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold)), + ], + ), + ), + ), + ), + ], Positioned( - top: 92, right: AppSpacing.lg, + bottom: isNavigating ? 190 : (state.selectedPlace == null ? AppSpacing.lg : 190), child: FloatingActionButton.small( - heroTag: 'filters', - tooltip: 'Filters', - backgroundColor: AppColors.surface, - foregroundColor: AppColors.primary, - onPressed: () => _showFilters(context), - child: const Icon(Icons.tune), + heroTag: 'location', + tooltip: 'Center on my location', + onPressed: _onMyLocationPressed, + child: _isLocating + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2, color: AppColors.primary), + ) + : const Icon(Icons.my_location), ), ), Positioned( - right: AppSpacing.lg, - bottom: state.selectedPlace == null ? AppSpacing.lg : 190, - child: FloatingActionButton.small( - heroTag: 'location', - tooltip: 'Use current location', - onPressed: () { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Demo mode is centered on Goa. Manual search still works without location.', - ), - ), - ); - }, - child: const Icon(Icons.my_location), + left: AppSpacing.lg, + bottom: isNavigating ? 190 : (state.selectedPlace == null ? AppSpacing.lg : 190), + child: FloatingActionButton.extended( + heroTag: 'sos', + backgroundColor: Colors.red.shade700, + foregroundColor: Colors.white, + onPressed: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const SosEmergencyScreen()), + ), + icon: const Icon(Icons.emergency), + label: const Text('SOS', style: TextStyle(fontWeight: FontWeight.bold)), ), ), + if (state.profile.accessibilityNeeds.contains(AccessibilityNeed.blindLowVision) && + !exploration.isActive && + !isNavigating) + Positioned( + left: AppSpacing.lg, + bottom: (state.selectedPlace == null ? AppSpacing.lg : 190) + 64, + child: FloatingActionButton.extended( + heroTag: 'discover', + backgroundColor: AppColors.secondary, + foregroundColor: AppColors.textOnPrimary, + onPressed: () => context.read().toggleExplorationMode(), + icon: const Icon(Icons.explore), + label: const Text('Start Exploration Mode'), + ), + ), if (state.errorMessage != null) EmptyState( title: "Couldn't load nearby places", @@ -96,17 +302,23 @@ class _MapScreenState extends State { bottom: AppSpacing.lg, child: PlacePreviewSheet( place: state.selectedPlace!, - userLatitude: 15.4909, - userLongitude: 73.8278, + userLatitude: exploration.currentEffectiveLat, + userLongitude: exploration.currentEffectiveLng, ), ), + const Positioned.fill( + child: ExplorationOverlay(), + ), ], ), ); } - List _filteredPlaces(List places) { + List _filteredPlaces(List places, TravelMode mode) { return places.where((place) { + if (!place.suitableForMode(mode)) { + return false; + } if (_categoryFilter != null && place.category != _categoryFilter) { return false; } @@ -120,59 +332,205 @@ class _MapScreenState extends State { void _showFilters(BuildContext context) { showModalBottomSheet( context: context, + isScrollControlled: true, showDragHandle: true, builder: (context) { return StatefulBuilder( builder: (context, setModalState) { - return Padding( - padding: const EdgeInsets.fromLTRB( - AppSpacing.xl, - 0, - AppSpacing.xl, - AppSpacing.xl, + return SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB( + AppSpacing.xl, + 0, + AppSpacing.xl, + AppSpacing.xl, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Filters', style: AppTypography.headlineSmall), + const SizedBox(height: AppSpacing.lg), + SwitchListTile( + value: _highlyFriendlyOnly, + onChanged: (value) { + setState(() => _highlyFriendlyOnly = value); + setModalState(() {}); + }, + title: const Text('Highly friendly'), + subtitle: const Text('Friendly Score 8.5 and above'), + ), + const SizedBox(height: AppSpacing.md), + Wrap( + spacing: AppSpacing.sm, + runSpacing: AppSpacing.sm, + children: [ + ChoiceChip( + label: const Text('All'), + selected: _categoryFilter == null, + onSelected: (_) { + setState(() => _categoryFilter = null); + setModalState(() {}); + }, + ), + ...PlaceCategory.values.map( + (category) => ChoiceChip( + avatar: Icon(category.icon, size: 18), + label: Text(category.displayName), + selected: _categoryFilter == category, + onSelected: (_) { + setState(() => _categoryFilter = category); + setModalState(() {}); + }, + ), + ), + ], + ), + ], + ), ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('Filters', style: AppTypography.headlineSmall), - const SizedBox(height: AppSpacing.lg), - SwitchListTile( - value: _highlyFriendlyOnly, - onChanged: (value) { - setState(() => _highlyFriendlyOnly = value); - setModalState(() {}); - }, - title: const Text('Highly friendly'), - subtitle: const Text('Friendly Score 8.5 and above'), + ); + }, + ); + }, + ); + } + + void _showMapStyleSelector(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (context) { + return StatefulBuilder( + builder: (context, setModalState) { + return SafeArea( + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.85, + ), + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB( + AppSpacing.lg, + 0, + AppSpacing.lg, + AppSpacing.xl, ), - const SizedBox(height: AppSpacing.md), - Wrap( - spacing: AppSpacing.sm, - runSpacing: AppSpacing.sm, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - ChoiceChip( - label: const Text('All'), - selected: _categoryFilter == null, - onSelected: (_) { - setState(() => _categoryFilter = null); - setModalState(() {}); - }, + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Text( + 'Map Layers & 3D', + style: AppTypography.titleLarge.copyWith(fontWeight: FontWeight.bold), + overflow: TextOverflow.ellipsis, + ), + ), + if (_is3DMode) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: AppColors.primary, + borderRadius: BorderRadius.circular(12), + ), + child: const Text( + '3D ACTIVE', + style: TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold), + ), + ), + ], ), - ...PlaceCategory.values.map( - (category) => ChoiceChip( - avatar: Icon(category.icon, size: 18), - label: Text(category.displayName), - selected: _categoryFilter == category, - onSelected: (_) { - setState(() => _categoryFilter = category); + const SizedBox(height: 4), + Text( + 'Select map visual layer or toggle 3D angled perspective:', + style: AppTypography.bodySmall.copyWith(color: AppColors.textSecondary), + ), + const SizedBox(height: AppSpacing.md), + + // 3D Perspective Mode Switch Tile + Container( + decoration: BoxDecoration( + color: _is3DMode ? AppColors.primarySurface : AppColors.surfaceVariant, + borderRadius: AppRadii.borderRadiusMd, + border: Border.all( + color: _is3DMode ? AppColors.primary : AppColors.divider, + width: _is3DMode ? 1.5 : 1.0, + ), + ), + child: SwitchListTile( + secondary: Icon( + Icons.view_in_ar, + color: _is3DMode ? AppColors.primary : AppColors.textSecondary, + size: 28, + ), + title: const Text('3D Angled Perspective', style: TextStyle(fontWeight: FontWeight.bold)), + subtitle: const Text('Angled camera rotation for 3D route orientation'), + value: _is3DMode, + onChanged: (val) { + setState(() { + _is3DMode = val; + _mapController.rotate(_is3DMode ? 35.0 : 0.0); + }); setModalState(() {}); }, ), ), + const SizedBox(height: AppSpacing.lg), + + Text('Base Map Layer', style: AppTypography.titleMedium.copyWith(fontWeight: FontWeight.bold)), + const SizedBox(height: AppSpacing.sm), + + // Map Styles Selection Cards + ...MapStyle.values.map((style) { + final isSelected = _currentMapStyle == style; + return Card( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + elevation: isSelected ? 2 : 0, + color: isSelected ? AppColors.primarySurface : AppColors.surface, + shape: RoundedRectangleBorder( + borderRadius: AppRadii.borderRadiusMd, + side: BorderSide( + color: isSelected ? AppColors.primary : AppColors.divider, + width: isSelected ? 1.5 : 1.0, + ), + ), + child: ListTile( + leading: CircleAvatar( + backgroundColor: isSelected ? AppColors.primary : AppColors.surfaceVariant, + foregroundColor: isSelected ? Colors.white : AppColors.textPrimary, + child: Icon(style.icon, size: 20), + ), + title: Text( + style.displayName, + style: TextStyle( + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + color: isSelected ? AppColors.primaryDark : AppColors.textPrimary, + ), + ), + subtitle: Text( + style.description, + style: const TextStyle(fontSize: 11), + ), + trailing: isSelected + ? const Icon(Icons.check_circle, color: AppColors.primary) + : null, + onTap: () { + setState(() => _currentMapStyle = style); + setModalState(() {}); + }, + ), + ); + }), ], ), - ], + ), ), ); }, @@ -186,49 +544,199 @@ class _AccessibleMap extends StatelessWidget { const _AccessibleMap({ required this.places, required this.selectedPlace, + this.activeDestination, + this.routePoints = const [], + this.hazards = const [], + this.userLocation, + this.mapController, + this.mapStyle = MapStyle.standard, + this.is3DMode = false, }); final List places; final Place? selectedPlace; + final Place? activeDestination; + final List routePoints; + final List hazards; + final LatLng? userLocation; + final MapController? mapController; + final MapStyle mapStyle; + final bool is3DMode; @override Widget build(BuildContext context) { + final centerPoint = activeDestination != null + ? LatLng(activeDestination!.latitude, activeDestination!.longitude) + : (userLocation ?? const LatLng(15.4909, 73.8278)); + return FlutterMap( - options: const MapOptions( - initialCenter: LatLng(15.4909, 73.8278), - initialZoom: 11, + mapController: mapController, + options: MapOptions( + initialCenter: centerPoint, + initialZoom: activeDestination != null ? 14.5 : 12, + initialRotation: is3DMode ? 35.0 : 0.0, + onTap: (_, __) => context.read().clearSelectedPlace(), ), children: [ TileLayer( - urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + urlTemplate: mapStyle.urlTemplate, userAgentPackageName: 'com.example.access_map', + subdomains: const ['a', 'b', 'c', 'd'], ), + // Draw walking route polyline when navigating + if (routePoints.isNotEmpty) + PolylineLayer( + polylines: [ + Polyline( + points: routePoints, + strokeWidth: 6, + color: const Color(0xFF0F766E), // Emerald walking path + borderStrokeWidth: 2, + borderColor: Colors.white, + ), + ], + ), MarkerLayer( - markers: places.map((place) { - final selected = place.id == selectedPlace?.id; - return Marker( - point: LatLng(place.latitude, place.longitude), - width: selected ? 58 : 48, - height: selected ? 58 : 48, - child: GestureDetector( - onTap: () => context.read().selectPlace(place), - child: AnimatedContainer( - duration: const Duration(milliseconds: 160), + markers: [ + // User location marker + if (userLocation != null) + Marker( + point: userLocation!, + width: 44, + height: 44, + child: Container( decoration: BoxDecoration( - color: selected ? AppColors.secondary : AppColors.primary, shape: BoxShape.circle, - border: Border.all(color: AppColors.surface, width: 3), - boxShadow: AppShadows.md, + color: Colors.blue.shade600, + border: Border.all(color: Colors.white, width: 3), + boxShadow: const [ + BoxShadow(color: Colors.black26, blurRadius: 8, offset: Offset(0, 3)), + ], ), - child: Icon( - place.category.icon, - color: AppColors.textOnPrimary, - size: selected ? 28 : 23, + child: const Center( + child: Icon(Icons.navigation, color: Colors.white, size: 20), ), ), ), - ); - }).toList(), + + // Active destination marker + if (activeDestination != null) + Marker( + point: LatLng(activeDestination!.latitude, activeDestination!.longitude), + width: 160, + height: 60, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFF0F766E), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.white, width: 1.5), + boxShadow: const [ + BoxShadow(color: Colors.black26, blurRadius: 6), + ], + ), + child: Text( + activeDestination!.name, + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.bold, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const Icon(Icons.location_on, color: Color(0xFF0F766E), size: 30), + ], + ), + ), + + // Live accessibility hazard markers (Crowdsourced Obstacles) + ...hazards.map((hazard) { + return Marker( + point: LatLng(hazard.latitude, hazard.longitude), + width: 42, + height: 42, + child: GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + behavior: SnackBarBehavior.floating, + backgroundColor: hazard.category.color, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + content: Row( + children: [ + Icon(hazard.category.icon, color: Colors.white, size: 22), + const SizedBox(width: 8), + Expanded( + child: Text( + '${hazard.category.label}: ${hazard.description} (${hazard.ageLabel})', + style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.white), + ), + ), + ], + ), + ), + ); + if (context.read().profile.accessibilityNeeds.contains(AccessibilityNeed.blindLowVision)) { + context.read().ttsService.speak( + 'Hazard: ${hazard.category.label}. ${hazard.description}.', + ); + } + }, + child: Container( + decoration: BoxDecoration( + color: hazard.category.color, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2.5), + boxShadow: [ + BoxShadow( + color: hazard.category.color.withValues(alpha: 0.45), + blurRadius: 8, + offset: const Offset(0, 3), + ), + ], + ), + child: Icon(hazard.category.icon, color: Colors.white, size: 20), + ), + ), + ); + }), + + // Regular places + ...places.map((place) { + final selected = place.id == selectedPlace?.id; + final isDest = place.id == activeDestination?.id; + if (isDest) return const Marker(point: LatLng(0, 0), child: SizedBox.shrink()); + + return Marker( + point: LatLng(place.latitude, place.longitude), + width: selected ? 58 : 48, + height: selected ? 58 : 48, + child: GestureDetector( + onTap: () => context.read().selectPlace(place), + child: AnimatedContainer( + duration: const Duration(milliseconds: 160), + decoration: BoxDecoration( + color: selected ? AppColors.secondary : AppColors.primary, + shape: BoxShape.circle, + border: Border.all(color: AppColors.surface, width: 3), + boxShadow: AppShadows.md, + ), + child: Icon( + place.category.icon, + color: AppColors.textOnPrimary, + size: selected ? 28 : 23, + ), + ), + ), + ); + }), + ], ), ], ); @@ -250,8 +758,21 @@ class PlacePreviewSheet extends StatelessWidget { @override Widget build(BuildContext context) { final relevant = place.availableFeatures.take(3).toList(); - return Card( - elevation: 0, + return Container( + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: AppRadii.borderRadiusMd, + border: Border.all(color: AppColors.border, width: 2), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.15), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + child: Material( + color: Colors.transparent, child: InkWell( borderRadius: AppRadii.borderRadiusMd, onTap: () => Navigator.of(context).push( @@ -275,20 +796,40 @@ class PlacePreviewSheet extends StatelessWidget { overflow: TextOverflow.ellipsis, ), ), - Text( - '${place.friendlyScore.toStringAsFixed(1)} Friendly', - style: AppTypography.titleMedium.copyWith( - color: AppColors.scoreColor(place.friendlyScore), + const SizedBox(width: AppSpacing.sm), + Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4), + decoration: BoxDecoration( + color: AppColors.scoreColor(place.friendlyScore).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.scoreColor(place.friendlyScore), width: 1.5), ), + child: Text( + '${place.friendlyScore.toStringAsFixed(1)} Friendly', + style: AppTypography.titleMedium.copyWith( + color: AppColors.scoreColor(place.friendlyScore), + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ), + const SizedBox(width: AppSpacing.xs), + IconButton( + icon: const Icon(Icons.close), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + onPressed: () => context.read().clearSelectedPlace(), ), ], ), const SizedBox(height: AppSpacing.xs), Text( '${place.category.displayName}' - '${userLatitude != null && userLongitude != null ? ' - ${_formatDistance(place.distanceKmFrom(userLatitude!, userLongitude!))}' : ''}' - ' - ${place.communityConfirmations} confirmations', + '${userLatitude != null && userLongitude != null ? ' • ${_formatDistance(place.distanceKmFrom(userLatitude!, userLongitude!))}' : ''}' + ' • ${place.communityConfirmations} confirmations', style: AppTypography.bodySmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, ), const SizedBox(height: AppSpacing.md), Wrap( @@ -308,6 +849,7 @@ class PlacePreviewSheet extends StatelessWidget { ), ), ), + ), ); } } @@ -327,14 +869,13 @@ class _SearchResults extends StatelessWidget { @override Widget build(BuildContext context) { final state = context.watch(); - return Container( - constraints: const BoxConstraints(maxHeight: 250), - margin: const EdgeInsets.only(top: AppSpacing.sm), - decoration: BoxDecoration( - color: AppColors.surface, - borderRadius: AppRadii.borderRadiusMd, - boxShadow: AppShadows.md, - ), + return Material( + color: AppColors.surface, + borderRadius: AppRadii.borderRadiusMd, + elevation: 4, + child: Container( + constraints: const BoxConstraints(maxHeight: 250), + margin: const EdgeInsets.only(top: AppSpacing.sm), child: state.isSearching ? const Padding( padding: EdgeInsets.all(AppSpacing.lg), @@ -368,6 +909,38 @@ class _SearchResults extends StatelessWidget { ); }, ), + ), + ); + } +} + +class _ModeIndicator extends StatelessWidget { + const _ModeIndicator({required this.mode}); + + final TravelMode mode; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + decoration: BoxDecoration( + color: AppColors.primarySurface, + borderRadius: AppRadii.borderRadiusMd, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(mode.icon, size: 16, color: AppColors.primary), + const SizedBox(width: AppSpacing.sm), + Text( + '${mode.displayName} mode', + style: AppTypography.labelMedium.copyWith(color: AppColors.primary), + ), + ], + ), ); } } diff --git a/lib/features/onboarding/presentation/welcome_onboarding_screen.dart b/lib/features/onboarding/presentation/welcome_onboarding_screen.dart new file mode 100644 index 0000000..bfe34c3 --- /dev/null +++ b/lib/features/onboarding/presentation/welcome_onboarding_screen.dart @@ -0,0 +1,273 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:access_map/app/app_state.dart'; +import 'package:access_map/core/theme/app_theme.dart'; +import 'package:access_map/shared/models/accessibility_need.dart'; + +/// Modern, welcoming initial screen when launching AccessSetu. +/// Introduces core capabilities and allows quick 1-tap disability focus selection +/// or immediate entry to the interactive map. +class WelcomeOnboardingScreen extends StatefulWidget { + const WelcomeOnboardingScreen({super.key}); + + @override + State createState() => _WelcomeOnboardingScreenState(); +} + +class _WelcomeOnboardingScreenState extends State { + final Set _selectedNeeds = {}; + + @override + void initState() { + super.initState(); + final currentNeeds = context.read().profile.accessibilityNeeds; + _selectedNeeds.addAll(currentNeeds); + } + + void _toggleNeed(AccessibilityNeed need) { + setState(() { + if (_selectedNeeds.contains(need)) { + _selectedNeeds.remove(need); + } else { + _selectedNeeds.add(need); + } + }); + } + + void _finishAndEnterMap(BuildContext context) { + final state = context.read(); + + // Save selected needs + for (final need in AccessibilityNeed.values) { + final isCurrentlySet = state.profile.accessibilityNeeds.contains(need); + final shouldBeSet = _selectedNeeds.contains(need); + if (isCurrentlySet != shouldBeSet) { + state.toggleNeed(need); + } + } + + state.completeOnboarding(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.background, + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xl, vertical: AppSpacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: AppSpacing.md), + + // Brand Logo & Header + Center( + child: Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: AppColors.primary, + borderRadius: BorderRadius.circular(24), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.35), + blurRadius: 20, + offset: const Offset(0, 8), + ), + ], + ), + child: const Center( + child: Icon( + Icons.accessibility_new_rounded, + color: Colors.white, + size: 48, + ), + ), + ), + ), + const SizedBox(height: AppSpacing.lg), + + Text( + 'AccessSetu', + style: AppTypography.displayMedium.copyWith( + fontWeight: FontWeight.bold, + letterSpacing: -0.5, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 6), + Text( + 'Personal Accessibility GPS & Verified Community Discovery for India', + style: AppTypography.bodyMedium.copyWith(color: AppColors.textSecondary), + textAlign: TextAlign.center, + ), + const SizedBox(height: AppSpacing.xl), + + // Feature Highlights Grid / Cards + Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: AppRadii.borderRadiusLg, + border: Border.all(color: AppColors.divider), + boxShadow: const [ + BoxShadow(color: Colors.black12, blurRadius: 8, offset: Offset(0, 3)), + ], + ), + child: Column( + children: const [ + _FeatureRow( + icon: Icons.map, + iconColor: Colors.teal, + title: 'Accessible Discovery & Navigation', + subtitle: 'Find verified step-free venues, ramps, wide doors, and hospitals in Goa.', + ), + Divider(height: 20), + _FeatureRow( + icon: Icons.explore, + iconColor: Colors.blue, + title: 'Lazarillo Spoken Guidance', + subtitle: '360° audio explorer and spoken landmarks ("Where Am I?") for blind navigation.', + ), + Divider(height: 20), + _FeatureRow( + icon: Icons.badge, + iconColor: Colors.amber, + title: 'Digital Assistance ID & Medical Card', + subtitle: 'Offline accessibility assistance ID and stored documents to assist conductors and officials.', + ), + Divider(height: 20), + _FeatureRow( + icon: Icons.emergency, + iconColor: Colors.red, + title: 'Smart SOS Safety Beacon', + subtitle: 'Voice broadcast, ICE medical QR code, and accidental-tap protection.', + ), + ], + ), + ), + const SizedBox(height: AppSpacing.xl), + + // Quick Persona / Need Selection (Optional) + Text( + 'Select your accessibility focus (Optional)', + style: AppTypography.titleMedium.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 4), + Text( + 'Tap to customize guidance now, or change anytime in your profile:', + style: AppTypography.bodySmall.copyWith(color: AppColors.textSecondary), + ), + const SizedBox(height: AppSpacing.md), + + Wrap( + spacing: 8, + runSpacing: 8, + children: AccessibilityNeed.values.map((need) { + final isSelected = _selectedNeeds.contains(need); + return FilterChip( + avatar: Icon( + need.icon, + size: 18, + color: isSelected ? Colors.white : AppColors.primary, + ), + label: Text(need.displayName), + selected: isSelected, + selectedColor: AppColors.primary, + checkmarkColor: Colors.white, + labelStyle: TextStyle( + color: isSelected ? Colors.white : AppColors.textPrimary, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + ), + onSelected: (_) => _toggleNeed(need), + ); + }).toList(), + ), + const SizedBox(height: AppSpacing.xxl), + + // Primary Action: Open Map + Semantics( + button: true, + label: 'Get started and open the accessible map', + child: ElevatedButton.icon( + onPressed: () => _finishAndEnterMap(context), + icon: const Icon(Icons.arrow_forward), + label: const Text('GET STARTED & EXPLORE MAP'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + minimumSize: const Size.fromHeight(56), + textStyle: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + shape: RoundedRectangleBorder( + borderRadius: AppRadii.borderRadiusMd, + ), + ), + ), + ), + const SizedBox(height: AppSpacing.md), + + // Assistant Mode Hint + Center( + child: Text( + 'Traveling with an assistant? Toggle Personal Assistant (PA) mode anytime in Profile & Settings.', + style: AppTypography.bodySmall.copyWith(color: AppColors.textSecondary, fontSize: 11), + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: AppSpacing.lg), + ], + ), + ), + ), + ); + } +} + +class _FeatureRow extends StatelessWidget { + const _FeatureRow({ + required this.icon, + required this.iconColor, + required this.title, + required this.subtitle, + }); + + final IconData icon; + final Color iconColor; + final String title; + final String subtitle; + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: iconColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, color: iconColor, size: 24), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: AppTypography.bodySmall.copyWith(color: AppColors.textSecondary), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/features/places/presentation/place_details_screen.dart b/lib/features/places/presentation/place_details_screen.dart index 9092cae..5ac8dea 100644 --- a/lib/features/places/presentation/place_details_screen.dart +++ b/lib/features/places/presentation/place_details_screen.dart @@ -3,6 +3,7 @@ import 'package:access_map/core/theme/app_theme.dart'; import 'package:access_map/core/utils/accessibility_visibility_policy.dart'; import 'package:access_map/features/reviews/presentation/write_review_screen.dart'; import 'package:access_map/shared/models/accessibility_feature.dart'; +import 'package:access_map/shared/models/accessibility_need.dart'; import 'package:access_map/shared/models/place.dart'; import 'package:access_map/shared/models/place_review.dart'; import 'package:access_map/shared/widgets/app_components.dart'; @@ -36,6 +37,8 @@ class PlaceDetailsScreen extends StatelessWidget { return b.createdAt.compareTo(a.createdAt); }); + final isBlind = context.watch().profile.accessibilityNeeds.contains(AccessibilityNeed.blindLowVision); + return Scaffold( appBar: AppBar(title: Text(place.name)), body: ListView( @@ -43,18 +46,40 @@ class PlaceDetailsScreen extends StatelessWidget { padding: const EdgeInsets.all(AppSpacing.lg), children: [ _HeroPanel(place: place), - const SizedBox(height: AppSpacing.lg), - PrimaryButton( - label: 'Get Directions', - icon: Icons.directions, - onPressed: () async { - final opened = await context.read().openDirections(place); - if (!opened && context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Directions are unavailable on this device.')), - ); - } - }, + Row( + children: [ + Expanded( + child: PrimaryButton( + label: isBlind ? 'Voice Navigation' : 'Start Navigation', + icon: isBlind ? Icons.record_voice_over : Icons.navigation, + onPressed: () { + final appState = context.read(); + appState.explorationService.startExploration(); + appState.explorationService.setDestination(place); + Navigator.of(context).pop(); + }, + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: OutlinedButton.icon( + onPressed: () async { + final opened = await context.read().openDirections(place); + if (!opened && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Directions are unavailable on this device.')), + ); + } + }, + icon: const Icon(Icons.map_outlined, size: 18), + label: const Text('Google Maps', maxLines: 1, overflow: TextOverflow.ellipsis), + style: OutlinedButton.styleFrom( + minimumSize: const Size(0, 52), + shape: RoundedRectangleBorder(borderRadius: AppRadii.borderRadiusMd), + ), + ), + ), + ], ), const SectionHeader('Accessibility overview'), _ExplainableScore( @@ -165,7 +190,7 @@ class PlaceDetailsScreen extends StatelessWidget { shrinkWrap: true, padding: const EdgeInsets.all(AppSpacing.lg), children: [ - const Text('Report review', style: AppTypography.headlineSmall), + Text('Report review', style: AppTypography.headlineSmall), const SizedBox(height: AppSpacing.md), ...reasons.map( (reason) => ListTile( @@ -293,30 +318,34 @@ class _ExplainableScore extends StatelessWidget { @override Widget build(BuildContext context) { - return InkWell( - borderRadius: AppRadii.borderRadiusMd, - onTap: () => _showExplanation(context), - child: Column( - children: [ - ScoreCard( - label: label, - score: score, - icon: icon, - prominent: prominent, - ), - const SizedBox(height: AppSpacing.xs), - ClipRRect( - borderRadius: AppRadii.borderRadiusFull, - child: LinearProgressIndicator( - value: score / 10, - minHeight: 6, - backgroundColor: AppColors.surfaceVariant, - valueColor: AlwaysStoppedAnimation( - AppColors.scoreColor(score), + return Semantics( + button: true, + label: '$label: ${score.toStringAsFixed(1)} out of 10. Double tap for community rating breakdown.', + child: InkWell( + borderRadius: AppRadii.borderRadiusMd, + onTap: () => _showExplanation(context), + child: Column( + children: [ + ScoreCard( + label: label, + score: score, + icon: icon, + prominent: prominent, + ), + const SizedBox(height: AppSpacing.xs), + ClipRRect( + borderRadius: AppRadii.borderRadiusFull, + child: LinearProgressIndicator( + value: score / 10, + minHeight: 6, + backgroundColor: AppColors.surfaceVariant, + valueColor: AlwaysStoppedAnimation( + AppColors.scoreColor(score), + ), ), ), - ), - ], + ], + ), ), ); } @@ -325,8 +354,9 @@ class _ExplainableScore extends StatelessWidget { showModalBottomSheet( context: context, showDragHandle: true, + isScrollControlled: true, builder: (context) => SafeArea( - child: Padding( + child: SingleChildScrollView( padding: const EdgeInsets.all(AppSpacing.xl), child: Column( mainAxisSize: MainAxisSize.min, diff --git a/lib/features/profile/presentation/disability_pass_screen.dart b/lib/features/profile/presentation/disability_pass_screen.dart new file mode 100644 index 0000000..a6542e2 --- /dev/null +++ b/lib/features/profile/presentation/disability_pass_screen.dart @@ -0,0 +1,745 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:access_map/app/app_state.dart'; +import 'package:access_map/core/theme/app_theme.dart'; +import 'package:access_map/features/profile/presentation/edit_profile_screen.dart'; + +/// Full-screen digital disability pass and medical certificate viewer +/// Designed for easy 1-tap presentation to transit conductors, station staff, +/// airline personnel, hospital triage, and security guards. +class DisabilityPassScreen extends StatefulWidget { + const DisabilityPassScreen({super.key}); + + @override + State createState() => _DisabilityPassScreenState(); +} + +class _DisabilityPassScreenState extends State { + bool _highContrastMode = false; + + void _speakPassDetails(BuildContext context) { + final state = context.read(); + final profile = state.profile; + final med = profile.medicalInfo; + + final udid = med?.udidNumber.isNotEmpty == true ? med!.udidNumber : 'Not registered'; + final category = med?.disabilityCategory.isNotEmpty == true + ? med!.disabilityCategory + : (med?.condition.isNotEmpty == true ? med!.condition : 'Disability assistance required'); + final percent = med?.disabilityPercentage.isNotEmpty == true + ? ', ${med!.disabilityPercentage}' + : ''; + final authority = med?.issuingAuthority.isNotEmpty == true + ? ' Issued by ${med!.issuingAuthority}.' + : ''; + final emergency = profile.emergencyContacts.isNotEmpty + ? ' Emergency contact: ${profile.emergencyContacts.first.name} at ${profile.emergencyContacts.first.phone}.' + : ''; + + final spokenText = + 'Accessibility Assistance ID Card for ${profile.displayName}. ' + 'Registration or U D I D number: $udid. ' + 'Category: $category$percent.$authority$emergency'; + + state.ttsService.speak(spokenText); + } + + void _openDocumentPreview(BuildContext context, String docName) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Row( + children: [ + const Icon(Icons.description, color: AppColors.primary), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + docName.isNotEmpty ? docName : 'Official Medical Certificate', + style: AppTypography.titleMedium, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: Colors.amber.shade50, + border: Border.all(color: Colors.amber.shade300), + borderRadius: AppRadii.borderRadiusSm, + ), + child: Row( + children: [ + const Icon(Icons.verified, color: Colors.green, size: 20), + const SizedBox(width: AppSpacing.xs), + Expanded( + child: Text( + 'Stored Medical Certificate (Self-Uploaded Document)', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + color: Colors.green.shade900, + ), + ), + ), + ], + ), + ), + const SizedBox(height: AppSpacing.md), + // Simulated Certificate Sheet + Container( + width: double.infinity, + padding: const EdgeInsets.all(AppSpacing.lg), + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: Colors.grey.shade400), + borderRadius: AppRadii.borderRadiusMd, + boxShadow: const [ + BoxShadow(color: Colors.black12, blurRadius: 8, offset: Offset(0, 3)), + ], + ), + child: Column( + children: [ + const Icon(Icons.account_balance, size: 36, color: Color(0xFF0D47A1)), + const SizedBox(height: 4), + const Text( + 'GOVERNMENT OF INDIA\nDEPARTMENT OF EMPOWERMENT OF PERSONS WITH DISABILITIES', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + letterSpacing: 0.5, + color: Colors.black87, + ), + ), + const Divider(height: 16), + const Text( + 'CERTIFICATE OF DISABILITY & MEDICAL ID', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Colors.black, + decoration: TextDecoration.underline, + ), + ), + const SizedBox(height: 12), + Text( + 'This certifies that the holder has undergone medical examination and is diagnosed with the documented disability condition per the Rights of Persons with Disabilities Act, 2016.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 9, color: Colors.grey.shade800, height: 1.4), + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(4), + ), + child: const Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Column( + children: [ + Text('SEAL & STAMP', style: TextStyle(fontSize: 8, fontWeight: FontWeight.bold)), + SizedBox(height: 2), + Icon(Icons.verified_user, color: Colors.blue, size: 28), + ], + ), + Column( + children: [ + Text('MEDICAL SUPERINTENDENT', style: TextStyle(fontSize: 8, fontWeight: FontWeight.bold)), + SizedBox(height: 2), + Icon(Icons.draw, color: Colors.black54, size: 28), + ], + ), + ], + ), + ), + ], + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('CLOSE'), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final state = context.watch(); + final profile = state.profile; + final med = profile.medicalInfo; + + final udid = med?.udidNumber.isNotEmpty == true ? med!.udidNumber : 'GA0710119950034512'; + final category = med?.disabilityCategory.isNotEmpty == true + ? med!.disabilityCategory + : (med?.condition.isNotEmpty == true ? med!.condition : 'Locomotor / Mobility Assistance'); + final percentage = med?.disabilityPercentage.isNotEmpty == true + ? med!.disabilityPercentage + : '75% Permanent'; + final authority = med?.issuingAuthority.isNotEmpty == true + ? med!.issuingAuthority + : 'Goa Medical College (GMC) & Hospital, Bambolim'; + final issueDate = med?.issueDate.isNotEmpty == true ? med!.issueDate : '12/03/2023'; + final bloodGroup = med?.bloodGroup.isNotEmpty == true ? med!.bloodGroup : 'B+ Positive'; + final hasDoc = med?.hasUploadedDocument ?? true; + final docName = med?.documentName.isNotEmpty == true + ? med!.documentName + : 'Govt_UDID_Certificate_${profile.displayName.replaceAll(' ', '_')}.pdf'; + + // QR payload for offline digital verification + final qrPayload = jsonEncode({ + 'issuer': 'AccessSetu Self-Declared Profile', + 'name': profile.displayName, + 'udid': udid, + 'category': category, + 'percentage': percentage, + 'authority': authority, + 'date': issueDate, + 'blood': bloodGroup, + 'contacts': profile.emergencyContacts.map((c) => {'n': c.name, 'p': c.phone}).toList(), + }); + + final cardBgColor = _highContrastMode ? Colors.black : Colors.white; + final cardTextColor = _highContrastMode ? Colors.white : AppColors.textPrimary; + + return Scaffold( + backgroundColor: _highContrastMode ? Colors.black : AppColors.background, + appBar: AppBar( + title: const Text('Digital Disability Pass (Self-Declared)'), + actions: [ + IconButton( + icon: Icon(_highContrastMode ? Icons.wb_sunny : Icons.wb_sunny_outlined), + tooltip: _highContrastMode ? 'Normal Contrast' : 'Outdoor High Contrast Boost', + onPressed: () => setState(() => _highContrastMode = !_highContrastMode), + ), + IconButton( + icon: const Icon(Icons.volume_up), + tooltip: 'Read Out Pass Credentials', + onPressed: () => _speakPassDetails(context), + ), + IconButton( + icon: const Icon(Icons.edit), + tooltip: 'Edit Pass Details & Uploads', + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const EditProfileScreen(), + ), + ), + ), + ], + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Quick Info banner for user + Container( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md, vertical: AppSpacing.sm), + decoration: BoxDecoration( + color: AppColors.primarySurface, + borderRadius: AppRadii.borderRadiusMd, + border: Border.all(color: AppColors.primary.withValues(alpha: 0.3)), + ), + child: Row( + children: [ + const Icon(Icons.info_outline, color: AppColors.primary, size: 20), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + 'Present this pass to conductors, transit staff, security, or airline assistance for instant verification.', + style: AppTypography.bodySmall.copyWith(color: AppColors.primaryDark), + ), + ), + ], + ), + ), + const SizedBox(height: AppSpacing.md), + + // ========================================== + // MAIN OFFICIAL PASS CARD + // ========================================== + Container( + decoration: BoxDecoration( + color: cardBgColor, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: _highContrastMode ? Colors.amberAccent : AppColors.divider, + width: _highContrastMode ? 3 : 1.5, + ), + boxShadow: [ + BoxShadow( + color: _highContrastMode ? Colors.transparent : Colors.black12, + blurRadius: 14, + offset: const Offset(0, 6), + ), + ], + ), + clipBehavior: Clip.antiAlias, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Pass Card Top Header + Container( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md, vertical: AppSpacing.sm + 4), + decoration: BoxDecoration( + gradient: _highContrastMode + ? null + : const LinearGradient( + colors: [AppColors.primaryDark, AppColors.primary], + ), + color: _highContrastMode ? Colors.amberAccent : null, + ), + child: Row( + children: [ + Icon( + Icons.badge, + color: _highContrastMode ? Colors.black : Colors.white, + size: 26, + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'ACCESSIBILITY ASSISTANCE PASS', + style: TextStyle( + color: _highContrastMode ? Colors.black : Colors.white70, + fontSize: 10, + fontWeight: FontWeight.bold, + letterSpacing: 1, + ), + ), + Text( + 'UNIQUE DISABILITY ID (UDID)', + style: TextStyle( + color: _highContrastMode ? Colors.black : Colors.white, + fontSize: 13, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: _highContrastMode + ? Colors.black + : Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon( + Icons.check_circle, + size: 12, + color: _highContrastMode ? Colors.amberAccent : Colors.white, + ), + const SizedBox(width: 4), + Text( + 'DOC ON FILE', + style: TextStyle( + color: _highContrastMode ? Colors.amberAccent : Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ], + ), + ), + + // Cardholder Details Section + Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Avatar + CircleAvatar( + radius: 34, + backgroundColor: _highContrastMode ? Colors.amberAccent : AppColors.primarySurface, + foregroundColor: _highContrastMode ? Colors.black : AppColors.primary, + child: Text( + profile.displayName.isNotEmpty + ? profile.displayName.substring(0, 1).toUpperCase() + : 'A', + style: const TextStyle(fontSize: 30, fontWeight: FontWeight.bold), + ), + ), + const SizedBox(width: AppSpacing.md), + // Name & Registration + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + profile.displayName, + style: TextStyle( + color: cardTextColor, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 3), + Text( + 'UDID No: $udid', + style: TextStyle( + color: _highContrastMode ? Colors.amberAccent : AppColors.primaryDark, + fontSize: 13, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 6), + Wrap( + spacing: 6, + runSpacing: 4, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: _highContrastMode ? Colors.white12 : AppColors.surfaceVariant, + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: _highContrastMode ? Colors.white24 : AppColors.border, + ), + ), + child: Text( + 'Blood: $bloodGroup', + style: TextStyle( + color: _highContrastMode ? Colors.white : AppColors.textPrimary, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: _highContrastMode ? Colors.amber.withValues(alpha: 0.2) : AppColors.primarySurface, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + percentage, + style: TextStyle( + color: _highContrastMode ? Colors.amberAccent : AppColors.primaryDark, + fontSize: 11, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ], + ), + ), + ], + ), + const SizedBox(height: AppSpacing.lg), + + // Key Medical / Disability Table + Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: _highContrastMode ? Colors.white.withValues(alpha: 0.05) : AppColors.surfaceVariant, + borderRadius: AppRadii.borderRadiusMd, + border: Border.all(color: _highContrastMode ? Colors.white12 : AppColors.divider), + ), + child: Column( + children: [ + _PassRow( + label: 'Disability / Need', + value: category, + isHighContrast: _highContrastMode, + ), + Divider(color: _highContrastMode ? Colors.white12 : AppColors.divider, height: 16), + _PassRow( + label: 'Issuing Authority', + value: authority, + isHighContrast: _highContrastMode, + ), + Divider(color: _highContrastMode ? Colors.white12 : AppColors.divider, height: 16), + _PassRow( + label: 'Issue Date', + value: issueDate, + isHighContrast: _highContrastMode, + ), + if (med?.allergies.isNotEmpty == true) ...[ + Divider(color: _highContrastMode ? Colors.white12 : AppColors.divider, height: 16), + _PassRow( + label: 'Allergies', + value: med!.allergies, + isHighContrast: _highContrastMode, + valueColor: AppColors.error, + ), + ], + ], + ), + ), + const SizedBox(height: AppSpacing.lg), + + // QR Code for Instant Inspector Verification + Center( + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: _highContrastMode ? Colors.amberAccent : AppColors.divider, + width: _highContrastMode ? 2 : 1, + ), + boxShadow: const [ + BoxShadow(color: Colors.black12, blurRadius: 6, offset: Offset(0, 2)), + ], + ), + child: QrImageView( + data: qrPayload, + version: QrVersions.auto, + size: 150.0, + ), + ), + ), + const SizedBox(height: 8), + Text( + 'Scan to verify credentials & view emergency contact details offline', + textAlign: TextAlign.center, + style: TextStyle( + color: _highContrastMode ? Colors.white70 : AppColors.textSecondary, + fontSize: 11, + ), + ), + ], + ), + ), + + // Bottom Bar of Card + Container( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md, vertical: 10), + decoration: BoxDecoration( + color: _highContrastMode ? Colors.black45 : AppColors.primarySurface, + borderRadius: const BorderRadius.vertical(bottom: Radius.circular(18)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'ACCESSSETU SECURE PASS', + style: TextStyle( + color: _highContrastMode ? Colors.white54 : AppColors.primaryDark, + fontSize: 10, + fontWeight: FontWeight.w600, + letterSpacing: 1, + ), + ), + Row( + children: [ + Icon( + Icons.shield_outlined, + color: _highContrastMode ? Colors.greenAccent : AppColors.primary, + size: 14, + ), + const SizedBox(width: 4), + Text( + 'OFFLINE ID', + style: TextStyle( + color: _highContrastMode ? Colors.greenAccent : AppColors.primary, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: AppSpacing.xl), + + // ========================================== + // ATTACHED MEDICAL CERTIFICATE / DOCUMENT + // ========================================== + Text( + 'Attached Medical Certificate / Scans', + style: AppTypography.headlineSmall, + ), + const SizedBox(height: AppSpacing.sm), + Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: AppRadii.borderRadiusMd, + side: const BorderSide(color: AppColors.divider), + ), + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColors.primarySurface, + borderRadius: AppRadii.borderRadiusSm, + ), + child: const Icon(Icons.picture_as_pdf, color: AppColors.primary, size: 28), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + docName, + style: AppTypography.titleMedium.copyWith(fontWeight: FontWeight.bold), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + hasDoc ? 'Uploaded & Stored Locally (Offline)' : 'No document file attached', + style: AppTypography.bodySmall.copyWith( + color: hasDoc ? AppColors.success : AppColors.textSecondary, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: AppSpacing.md), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () => _openDocumentPreview(context, docName), + icon: const Icon(Icons.visibility, size: 16), + label: const Text('View Doc', maxLines: 1, overflow: TextOverflow.ellipsis), + style: OutlinedButton.styleFrom( + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), + ), + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: ElevatedButton.icon( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const EditProfileScreen(), + ), + ), + icon: const Icon(Icons.upload_file, size: 16), + label: const Text('Upload / Edit', maxLines: 1, overflow: TextOverflow.ellipsis), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), + ), + ), + ), + ], + ), + ], + ), + ), + ), + const SizedBox(height: AppSpacing.lg), + + // Emergency Contacts Quick Display + if (profile.emergencyContacts.isNotEmpty) ...[ + Text( + 'Designated Emergency Contacts', + style: AppTypography.headlineSmall, + ), + const SizedBox(height: AppSpacing.sm), + ...profile.emergencyContacts.map( + (c) => Card( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + child: ListTile( + leading: const CircleAvatar( + backgroundColor: AppColors.primarySurface, + child: Icon(Icons.phone, color: AppColors.primary), + ), + title: Text(c.name, style: const TextStyle(fontWeight: FontWeight.bold)), + subtitle: Text('${c.relation} • ${c.phone}'), + ), + ), + ), + ], + const SizedBox(height: AppSpacing.xl), + ], + ), + ), + ); + } +} + +class _PassRow extends StatelessWidget { + const _PassRow({ + required this.label, + required this.value, + required this.isHighContrast, + this.valueColor, + }); + + final String label; + final String value; + final bool isHighContrast; + final Color? valueColor; + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 120, + child: Text( + label, + style: TextStyle( + color: isHighContrast ? Colors.amberAccent : AppColors.textSecondary, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + Expanded( + child: Text( + value, + style: TextStyle( + color: valueColor ?? (isHighContrast ? Colors.white : AppColors.textPrimary), + fontSize: 13, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ); + } +} diff --git a/lib/features/profile/presentation/edit_profile_screen.dart b/lib/features/profile/presentation/edit_profile_screen.dart new file mode 100644 index 0000000..b58f50d --- /dev/null +++ b/lib/features/profile/presentation/edit_profile_screen.dart @@ -0,0 +1,928 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:access_map/app/app_state.dart'; +import 'package:access_map/core/services/certificate_ocr_service.dart'; +import 'package:access_map/core/theme/app_theme.dart'; +import 'package:access_map/shared/models/user_profile.dart'; +import 'package:access_map/shared/models/accessibility_need.dart'; +import 'package:access_map/shared/widgets/app_components.dart'; + +class EditProfileScreen extends StatefulWidget { + const EditProfileScreen({super.key}); + + @override + State createState() => _EditProfileScreenState(); +} + +class _EditProfileScreenState extends State { + final _formKey = GlobalKey(); + + late final TextEditingController _nameController; + late final TextEditingController _conditionController; + late final TextEditingController _allergiesController; + late final TextEditingController _instructionsController; + late final TextEditingController _udidController; + late final TextEditingController _categoryController; + late final TextEditingController _percentageController; + late final TextEditingController _authorityController; + late final TextEditingController _bloodGroupController; + + late bool _hasUploadedDocument; + late String _documentName; + bool _isOcrScanning = false; + final _ocrService = CertificateOcrService(); + + final _contactNameController = TextEditingController(); + final _contactPhoneController = TextEditingController(); + final _contactRelationController = TextEditingController(); + + late List _emergencyContacts; + + @override + void initState() { + super.initState(); + final profile = context.read().profile; + final med = profile.medicalInfo; + + _nameController = TextEditingController(text: profile.displayName); + _conditionController = TextEditingController(text: med?.condition ?? ''); + _allergiesController = TextEditingController(text: med?.allergies ?? ''); + _instructionsController = TextEditingController(text: med?.instructions ?? ''); + _udidController = TextEditingController(text: med?.udidNumber ?? 'GA0710119950034512'); + _categoryController = TextEditingController(text: med?.disabilityCategory ?? 'Visual Impairment (Low Vision)'); + _percentageController = TextEditingController(text: med?.disabilityPercentage ?? '75% Permanent'); + _authorityController = TextEditingController(text: med?.issuingAuthority ?? 'Goa Medical College (GMC) & Hospital'); + _bloodGroupController = TextEditingController(text: med?.bloodGroup ?? 'B+ Positive'); + _hasUploadedDocument = med?.hasUploadedDocument ?? true; + _documentName = med?.documentName.isNotEmpty == true + ? med!.documentName + : 'Govt_UDID_Certificate_${profile.displayName.replaceAll(' ', '_')}.pdf'; + _emergencyContacts = List.from(profile.emergencyContacts); + } + + @override + void dispose() { + _nameController.dispose(); + _conditionController.dispose(); + _allergiesController.dispose(); + _instructionsController.dispose(); + _udidController.dispose(); + _categoryController.dispose(); + _percentageController.dispose(); + _authorityController.dispose(); + _bloodGroupController.dispose(); + _contactNameController.dispose(); + _contactPhoneController.dispose(); + _contactRelationController.dispose(); + super.dispose(); + } + + void _addContact() { + final name = _contactNameController.text.trim(); + final phone = _contactPhoneController.text.trim(); + final relation = _contactRelationController.text.trim(); + + if (name.isEmpty || phone.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please provide contact name and phone number.')), + ); + return; + } + + setState(() { + _emergencyContacts.add(EmergencyContact( + name: name, + phone: phone, + relation: relation.isEmpty ? 'Emergency Contact' : relation, + )); + _contactNameController.clear(); + _contactPhoneController.clear(); + _contactRelationController.clear(); + }); + } + + void _addCurrentPaContact() { + setState(() { + _emergencyContacts.add(const EmergencyContact( + name: 'Personal Assistant (Caregiver)', + phone: '+91 9822 101010', + relation: 'Personal Assistant / Caregiver', + )); + }); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Personal Assistant added to emergency contacts.')), + ); + } + + void _removeContact(int index) { + setState(() { + _emergencyContacts.removeAt(index); + }); + } + + void _showDocumentUploadPicker() { + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (ctx) => SafeArea( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Upload / Attach Medical Certificate', + style: AppTypography.headlineSmall, + ), + const SizedBox(height: AppSpacing.sm), + Text( + 'Select a document source to attach your government UDID card or medical disability certificate:', + style: AppTypography.bodySmall.copyWith(color: AppColors.textSecondary), + ), + const SizedBox(height: AppSpacing.md), + ListTile( + leading: const CircleAvatar( + backgroundColor: AppColors.primarySurface, + child: Icon(Icons.camera_alt, color: AppColors.primary), + ), + title: const Text('Scan with Device Camera'), + subtitle: const Text('Capture certificate page or physical UDID card'), + onTap: () { + Navigator.of(ctx).pop(); + setState(() { + _hasUploadedDocument = true; + _documentName = 'UDID_Card_Scan_${DateTime.now().millisecondsSinceEpoch}.jpg'; + }); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Certificate photo captured and attached successfully!')), + ); + }, + ), + ListTile( + leading: const CircleAvatar( + backgroundColor: AppColors.primarySurface, + child: Icon(Icons.picture_as_pdf, color: AppColors.primary), + ), + title: const Text('Choose PDF / Document File'), + subtitle: const Text('Select existing medical certificate from storage'), + onTap: () { + Navigator.of(ctx).pop(); + setState(() { + _hasUploadedDocument = true; + _documentName = 'Medical_Board_Certificate_Goa.pdf'; + }); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Document attached locally to profile.')), + ); + }, + ), + ListTile( + leading: const CircleAvatar( + backgroundColor: AppColors.primarySurface, + child: Icon(Icons.account_balance, color: AppColors.primary), + ), + title: const Text('Attach Sample e-UDID Document (Demo)'), + subtitle: const Text('Simulate attaching an e-UDID document for testing'), + onTap: () { + Navigator.of(ctx).pop(); + setState(() { + _hasUploadedDocument = true; + _documentName = 'Sample_UDID_eCard_${_udidController.text.trim().isNotEmpty ? _udidController.text.trim() : "Demo"}.pdf'; + }); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Sample e-UDID document attached to profile.')), + ); + }, + ), + ], + ), + ), + ), + ); + } + + void _save() { + if (!_formKey.currentState!.validate()) return; + + final newName = _nameController.text.trim(); + final updatedMedical = MedicalInfo( + condition: _conditionController.text.trim(), + allergies: _allergiesController.text.trim(), + instructions: _instructionsController.text.trim(), + udidNumber: _udidController.text.trim(), + disabilityCategory: _categoryController.text.trim(), + disabilityPercentage: _percentageController.text.trim(), + issuingAuthority: _authorityController.text.trim(), + bloodGroup: _bloodGroupController.text.trim(), + hasUploadedDocument: _hasUploadedDocument, + documentName: _documentName, + ); + + context.read().updateProfile( + displayName: newName.isNotEmpty ? newName : 'User', + medicalInfo: updatedMedical, + emergencyContacts: _emergencyContacts, + ); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Profile & Emergency Information saved!')), + ); + Navigator.of(context).pop(); + } + + void _showOcrPresetSelector() { + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (ctx) => SafeArea( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + const Icon(Icons.document_scanner, color: Color(0xFF0F766E), size: 26), + const SizedBox(width: AppSpacing.sm), + Text( + 'Select Certificate to Extract (Demo)', + style: AppTypography.titleLarge.copyWith(fontWeight: FontWeight.bold), + ), + ], + ), + const SizedBox(height: AppSpacing.xs), + Text( + 'Choose a sample document to auto-fill profile, medical, and assistance details:', + style: AppTypography.bodySmall.copyWith(color: AppColors.textSecondary), + ), + const SizedBox(height: AppSpacing.md), + ListTile( + leading: const CircleAvatar( + backgroundColor: Color(0xFFCCFBF1), + child: Icon(Icons.badge, color: Color(0xFF0F766E)), + ), + title: const Text('Sample UDID Card (Goa) - Aarav Sharma'), + subtitle: const Text('Low Vision • 75% • Goa Medical College'), + onTap: () { + Navigator.of(ctx).pop(); + _startOcrScan(presetIndex: 0); + }, + ), + ListTile( + leading: const CircleAvatar( + backgroundColor: Color(0xFFCCFBF1), + child: Icon(Icons.accessible, color: Color(0xFF0F766E)), + ), + title: const Text('Sample Hospital Certificate - Rohan Vernekar'), + subtitle: const Text('Wheelchair User • 80% • Mapusa Civil Hospital'), + onTap: () { + Navigator.of(ctx).pop(); + _startOcrScan(presetIndex: 1); + }, + ), + ListTile( + leading: const CircleAvatar( + backgroundColor: Color(0xFFCCFBF1), + child: Icon(Icons.hearing, color: Color(0xFF0F766E)), + ), + title: const Text('Sample Medical Pass - Sneha Kulkarni'), + subtitle: const Text('Speech & Hearing • 100% • AIIPMR Institute'), + onTap: () { + Navigator.of(ctx).pop(); + _startOcrScan(presetIndex: 2); + }, + ), + ListTile( + leading: const CircleAvatar( + backgroundColor: AppColors.primarySurface, + child: Icon(Icons.camera_alt, color: AppColors.primary), + ), + title: const Text('Simulate Document Capture with Camera'), + subtitle: const Text('Snap physical disability card or medical certificate'), + onTap: () { + Navigator.of(ctx).pop(); + _startOcrScan(presetIndex: 0); + }, + ), + ], + ), + ), + ), + ); + } + + Future _startOcrScan({int presetIndex = 0}) async { + setState(() => _isOcrScanning = true); + + try { + final extracted = await _ocrService.scanAndExtractDocument(presetIndex: presetIndex); + if (!mounted) return; + + setState(() => _isOcrScanning = false); + + showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (ctx) => Padding( + padding: EdgeInsets.only( + top: AppSpacing.lg, + left: AppSpacing.lg, + right: AppSpacing.lg, + bottom: MediaQuery.of(ctx).viewInsets.bottom + AppSpacing.lg, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.green.shade50, + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Icons.document_scanner, color: Colors.green, size: 28), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Document Details Extracted (Demo)', + style: AppTypography.titleLarge.copyWith(fontWeight: FontWeight.bold), + ), + Text( + 'Extracted 8 fields from ${extracted.documentTitle}', + style: AppTypography.bodySmall.copyWith(color: AppColors.textSecondary), + ), + ], + ), + ), + ], + ), + const SizedBox(height: AppSpacing.md), + const Divider(), + const SizedBox(height: AppSpacing.sm), + + Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.surfaceVariant, + borderRadius: AppRadii.borderRadiusMd, + border: Border.all(color: AppColors.divider), + ), + child: Column( + children: [ + _ocrPreviewRow('Cardholder Name', extracted.name), + _ocrPreviewRow('UDID Registration No.', extracted.udidNumber), + _ocrPreviewRow('Disability Category', extracted.disabilityCategory), + _ocrPreviewRow('Severity / %', extracted.disabilityPercentage), + _ocrPreviewRow('Issuing Hospital', extracted.issuingAuthority), + _ocrPreviewRow('Blood Group', extracted.bloodGroup), + _ocrPreviewRow('Allergies Alert', extracted.allergies), + ], + ), + ), + const SizedBox(height: AppSpacing.lg), + + ElevatedButton.icon( + onPressed: () { + Navigator.of(ctx).pop(); + _applyExtractedData(extracted); + }, + icon: const Icon(Icons.flash_on), + label: const Text('APPLY & AUTO-FILL TO PROFILE'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF0F766E), + foregroundColor: Colors.white, + minimumSize: const Size.fromHeight(50), + textStyle: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14), + ), + ), + ], + ), + ), + ); + } catch (_) { + if (mounted) setState(() => _isOcrScanning = false); + } + } + + Widget _ocrPreviewRow(String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: const TextStyle(fontSize: 12, color: AppColors.textSecondary)), + const SizedBox(width: 8), + Flexible( + child: Text( + value, + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold), + textAlign: TextAlign.end, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } + + void _applyExtractedData(ExtractedCertificateData data) { + setState(() { + _nameController.text = data.name; + _udidController.text = data.udidNumber; + _categoryController.text = data.disabilityCategory; + _percentageController.text = data.disabilityPercentage; + _authorityController.text = data.issuingAuthority; + _bloodGroupController.text = data.bloodGroup; + _allergiesController.text = data.allergies; + _conditionController.text = data.primaryCondition; + _instructionsController.text = data.emergencyInstructions; + _hasUploadedDocument = true; + _documentName = data.documentTitle; + }); + + if (context.read().profile.accessibilityNeeds.contains(AccessibilityNeed.blindLowVision)) { + context.read().ttsService.speak( + 'Medical certificate scanned. Profile details and UDID auto-filled successfully.', + ); + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + backgroundColor: Colors.green.shade800, + content: Row( + children: const [ + Icon(Icons.check_circle, color: Colors.white), + SizedBox(width: 8), + Expanded( + child: Text('All fields auto-filled from scanned medical certificate!'), + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Edit Profile & Emergency ID'), + ), + body: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(AppSpacing.lg), + children: [ + // AI OCR Smart Auto-Fill Banner + Container( + margin: const EdgeInsets.only(bottom: AppSpacing.lg), + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + const Color(0xFF0F766E).withValues(alpha: 0.15), + Colors.teal.shade50, + ], + ), + borderRadius: AppRadii.borderRadiusMd, + border: Border.all(color: const Color(0xFF0F766E).withValues(alpha: 0.4)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: const Color(0xFF0F766E), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Icons.document_scanner, color: Colors.white, size: 22), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Sample Certificate Auto-Fill (Demo)', + style: AppTypography.titleMedium.copyWith( + fontWeight: FontWeight.bold, + color: const Color(0xFF0F766E), + ), + ), + Text( + 'Select sample certificates or simulate scan to auto-fill fields', + style: AppTypography.bodySmall.copyWith(color: AppColors.textSecondary), + ), + ], + ), + ), + ], + ), + const SizedBox(height: AppSpacing.md), + Semantics( + button: true, + label: 'Auto-fill profile using sample medical certificate or document extraction demo', + child: ElevatedButton.icon( + onPressed: _isOcrScanning ? null : () => _showOcrPresetSelector(), + icon: _isOcrScanning + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ) + : const Icon(Icons.auto_awesome, size: 20), + label: Text(_isOcrScanning ? 'Extracting Details...' : 'SAMPLE DOCUMENT AUTO-FILL (DEMO)'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF0F766E), + foregroundColor: Colors.white, + minimumSize: const Size.fromHeight(46), + textStyle: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13), + ), + ), + ), + ], + ), + ), + + Text('Personal Identity', style: AppTypography.headlineSmall), + const SizedBox(height: AppSpacing.sm), + Text( + 'Enter your name so community contributions and emergency SOS screens display your verified identity.', + style: AppTypography.bodySmall.copyWith(color: AppColors.textSecondary), + ), + const SizedBox(height: AppSpacing.md), + Semantics( + label: 'Your full name input field', + child: TextFormField( + controller: _nameController, + decoration: const InputDecoration( + labelText: 'Your Full Name', + hintText: 'e.g. Aarav Sharma', + prefixIcon: Icon(Icons.person), + border: OutlineInputBorder(), + ), + validator: (val) => + (val == null || val.trim().isEmpty) ? 'Name cannot be blank' : null, + ), + ), + const SizedBox(height: AppSpacing.xl), + + // Emergency Medical Info + Text('Emergency & Medical Details', style: AppTypography.headlineSmall), + const SizedBox(height: AppSpacing.sm), + Text( + 'This is displayed simultaneously with speech when triggering the Emergency SOS or scanning your ICE QR code.', + style: AppTypography.bodySmall.copyWith(color: AppColors.textSecondary), + ), + const SizedBox(height: AppSpacing.md), + Semantics( + label: 'Primary medical conditions or disability notes input field', + child: TextFormField( + controller: _conditionController, + decoration: const InputDecoration( + labelText: 'Primary Condition / Disability Note', + hintText: 'e.g. Low vision, wheelchair user, non-verbal', + prefixIcon: Icon(Icons.medical_information), + border: OutlineInputBorder(), + ), + ), + ), + const SizedBox(height: AppSpacing.md), + Semantics( + label: 'Allergies and contraindications input field', + child: TextFormField( + controller: _allergiesController, + decoration: const InputDecoration( + labelText: 'Allergies & Medical Alerts', + hintText: 'e.g. Penicillin, latex, nuts', + prefixIcon: Icon(Icons.warning_amber), + border: OutlineInputBorder(), + ), + ), + ), + const SizedBox(height: AppSpacing.md), + Semantics( + label: 'Emergency instructions for bystanders and first responders input field', + child: TextFormField( + controller: _instructionsController, + maxLines: 3, + decoration: const InputDecoration( + labelText: 'Instructions for First Responders', + hintText: 'e.g. Non-verbal. Guide by arm. Please text instead of calling.', + prefixIcon: Icon(Icons.info_outline), + border: OutlineInputBorder(), + ), + ), + ), + const SizedBox(height: AppSpacing.xl), + + // Government UDID & Disability Certificate Section + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('UDID & Disability Certificate', style: AppTypography.headlineSmall), + const Icon(Icons.verified, color: Colors.green, size: 22), + ], + ), + const SizedBox(height: AppSpacing.sm), + Text( + 'Upload and verify your Unique Disability ID (UDID) or hospital certificate. You can show this pass anytime to transit staff, conductors, or security.', + style: AppTypography.bodySmall.copyWith(color: AppColors.textSecondary), + ), + const SizedBox(height: AppSpacing.md), + + // UDID Number Field + Semantics( + label: 'Unique Disability ID Number input field', + child: TextFormField( + controller: _udidController, + decoration: const InputDecoration( + labelText: 'UDID / Certificate Registration Number', + hintText: 'e.g. GA0710119950034512', + prefixIcon: Icon(Icons.badge), + border: OutlineInputBorder(), + ), + ), + ), + const SizedBox(height: AppSpacing.md), + + // Disability Category & Percentage in Row + Row( + children: [ + Expanded( + flex: 2, + child: Semantics( + label: 'Disability category input field', + child: TextFormField( + controller: _categoryController, + decoration: const InputDecoration( + labelText: 'Disability Category', + hintText: 'e.g. Locomotor, Low Vision', + prefixIcon: Icon(Icons.category), + border: OutlineInputBorder(), + ), + ), + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Semantics( + label: 'Disability percentage input field', + child: TextFormField( + controller: _percentageController, + decoration: const InputDecoration( + labelText: 'Severity / %', + hintText: 'e.g. 75%', + border: OutlineInputBorder(), + ), + ), + ), + ), + ], + ), + const SizedBox(height: AppSpacing.md), + + // Issuing Authority & Blood Group + Row( + children: [ + Expanded( + flex: 2, + child: Semantics( + label: 'Issuing medical authority or hospital input field', + child: TextFormField( + controller: _authorityController, + decoration: const InputDecoration( + labelText: 'Issuing Medical Authority', + hintText: 'e.g. Goa Medical College (GMC)', + prefixIcon: Icon(Icons.account_balance), + border: OutlineInputBorder(), + ), + ), + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Semantics( + label: 'Blood group input field', + child: TextFormField( + controller: _bloodGroupController, + decoration: const InputDecoration( + labelText: 'Blood Group', + hintText: 'e.g. B+', + border: OutlineInputBorder(), + ), + ), + ), + ), + ], + ), + const SizedBox(height: AppSpacing.md), + + // Uploaded Document Card + Card( + color: AppColors.surfaceVariant, + shape: RoundedRectangleBorder( + borderRadius: AppRadii.borderRadiusMd, + side: const BorderSide(color: AppColors.divider), + ), + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + children: [ + Row( + children: [ + Icon( + _hasUploadedDocument ? Icons.check_circle : Icons.upload_file, + color: _hasUploadedDocument ? AppColors.success : AppColors.primary, + size: 28, + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _hasUploadedDocument ? _documentName : 'No Certificate Attached Yet', + style: AppTypography.titleMedium.copyWith(fontWeight: FontWeight.bold), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + _hasUploadedDocument + ? 'Attached & Available Offline' + : 'Upload scanned PDF or photo of physical UDID', + style: AppTypography.bodySmall, + ), + ], + ), + ), + ], + ), + const SizedBox(height: AppSpacing.sm), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: _showDocumentUploadPicker, + icon: const Icon(Icons.upload, size: 18), + label: Text(_hasUploadedDocument ? 'Change Document' : 'Upload Document'), + ), + ), + if (_hasUploadedDocument) ...[ + const SizedBox(width: AppSpacing.sm), + IconButton( + icon: const Icon(Icons.delete_outline, color: AppColors.error), + tooltip: 'Remove Attached Document', + onPressed: () { + setState(() { + _hasUploadedDocument = false; + _documentName = ''; + }); + }, + ), + ], + ], + ), + ], + ), + ), + ), + const SizedBox(height: AppSpacing.xl), + + // Emergency Contacts + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Emergency Contacts (ICE)', style: AppTypography.headlineSmall), + TextButton.icon( + onPressed: _addCurrentPaContact, + icon: const Icon(Icons.handshake, size: 18), + label: const Text('Add Me (PA)'), + ), + ], + ), + const SizedBox(height: AppSpacing.xs), + Text( + 'These contacts will be notified via WhatsApp & phone during an SOS trigger.', + style: AppTypography.bodySmall.copyWith(color: AppColors.textSecondary), + ), + const SizedBox(height: AppSpacing.md), + + if (_emergencyContacts.isEmpty) + Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.surfaceVariant, + borderRadius: AppRadii.borderRadiusMd, + ), + child: const Text( + 'No emergency contacts added yet. Add at least one contact below.', + style: TextStyle(fontStyle: FontStyle.italic), + ), + ) + else + ..._emergencyContacts.asMap().entries.map((entry) { + final idx = entry.key; + final contact = entry.value; + return Card( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + child: ListTile( + leading: const CircleAvatar( + backgroundColor: AppColors.primarySurface, + foregroundColor: AppColors.primary, + child: Icon(Icons.contact_phone), + ), + title: Text(contact.name, style: AppTypography.titleMedium), + subtitle: Text('${contact.phone} • ${contact.relation}'), + trailing: IconButton( + icon: const Icon(Icons.delete_outline, color: AppColors.error), + onPressed: () => _removeContact(idx), + tooltip: 'Remove contact', + ), + ), + ); + }), + + const SizedBox(height: AppSpacing.md), + Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: AppRadii.borderRadiusMd, + side: const BorderSide(color: AppColors.divider), + ), + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('Add New Contact', style: AppTypography.titleMedium), + const SizedBox(height: AppSpacing.sm), + TextFormField( + controller: _contactNameController, + decoration: const InputDecoration( + labelText: 'Contact Name', + isDense: true, + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: AppSpacing.sm), + TextFormField( + controller: _contactPhoneController, + keyboardType: TextInputType.phone, + decoration: const InputDecoration( + labelText: 'Phone Number', + isDense: true, + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: AppSpacing.sm), + TextFormField( + controller: _contactRelationController, + decoration: const InputDecoration( + labelText: 'Relationship (e.g. Brother, Caregiver)', + isDense: true, + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: AppSpacing.md), + OutlinedButton.icon( + onPressed: _addContact, + icon: const Icon(Icons.add), + label: const Text('Add Contact to List'), + ), + ], + ), + ), + ), + const SizedBox(height: AppSpacing.xxl), + + PrimaryButton( + label: 'Save Profile & ICE Details', + icon: Icons.check, + onPressed: _save, + ), + const SizedBox(height: AppSpacing.xl), + ], + ), + ), + ); + } +} diff --git a/lib/features/profile/presentation/offline_maps_screen.dart b/lib/features/profile/presentation/offline_maps_screen.dart new file mode 100644 index 0000000..94c5265 --- /dev/null +++ b/lib/features/profile/presentation/offline_maps_screen.dart @@ -0,0 +1,292 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:access_map/app/app_state.dart'; +import 'package:access_map/core/services/offline_map_service.dart'; +import 'package:access_map/core/theme/app_theme.dart'; + +class OfflineMapsScreen extends StatelessWidget { + const OfflineMapsScreen({super.key}); + + @override + Widget build(BuildContext context) { + final offlineService = context.watch().offlineMapService; + final totalMb = offlineService.totalDownloadedMb; + + return Scaffold( + appBar: AppBar( + title: const Text('Offline Regional Maps'), + ), + body: ListView( + padding: const EdgeInsets.all(AppSpacing.lg), + children: [ + // Storage & Status Banner + Container( + padding: const EdgeInsets.all(AppSpacing.lg), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [AppColors.primaryDark, AppColors.primary], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: AppRadii.borderRadiusLg, + boxShadow: const [ + BoxShadow(color: Colors.black12, blurRadius: 10, offset: Offset(0, 4)), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: const [ + Icon(Icons.offline_pin, color: Colors.white, size: 24), + SizedBox(width: 8), + Text( + 'OFFLINE STORAGE', + style: TextStyle( + color: Colors.white70, + fontSize: 11, + fontWeight: FontWeight.bold, + letterSpacing: 1, + ), + ), + ], + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + totalMb > 0 ? 'OFFLINE READY' : 'NO MAPS SAVED', + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + const SizedBox(height: AppSpacing.md), + Text( + '$totalMb MB Used', + style: const TextStyle( + color: Colors.white, + fontSize: 26, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + 'Regional downloads save local streets, step-free paths & verified venues without downloading the entire world.', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.9), + fontSize: 13, + height: 1.3, + ), + ), + ], + ), + ), + const SizedBox(height: AppSpacing.lg), + + // Wi-Fi Only Settings Tile + Card( + shape: RoundedRectangleBorder( + borderRadius: AppRadii.borderRadiusMd, + side: const BorderSide(color: AppColors.divider), + ), + child: SwitchListTile( + secondary: const Icon(Icons.wifi, color: AppColors.primary), + title: const Text('Download on Wi-Fi Only'), + subtitle: const Text('Prevent cellular data usage when saving maps'), + value: offlineService.wifiOnly, + onChanged: (val) => offlineService.setWifiOnly(val), + ), + ), + const SizedBox(height: AppSpacing.xl), + + // Regional Packages Header + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Available Regional Packs', style: AppTypography.titleMedium.copyWith(fontWeight: FontWeight.bold)), + Text('${offlineService.packages.length} regions', style: AppTypography.bodySmall.copyWith(color: AppColors.textSecondary)), + ], + ), + const SizedBox(height: AppSpacing.sm), + + // Package Cards List + ...offlineService.packages.map((pkg) => _PackageCard(package: pkg)), + + const SizedBox(height: AppSpacing.xl), + ], + ), + ); + } +} + +class _PackageCard extends StatelessWidget { + const _PackageCard({required this.package}); + + final OfflineMapPackage package; + + @override + Widget build(BuildContext context) { + final offlineService = context.read().offlineMapService; + + return Card( + margin: const EdgeInsets.only(bottom: AppSpacing.md), + shape: RoundedRectangleBorder( + borderRadius: AppRadii.borderRadiusMd, + side: BorderSide( + color: package.isDownloaded + ? AppColors.primary.withValues(alpha: 0.5) + : AppColors.divider, + width: package.isDownloaded ? 1.5 : 1.0, + ), + ), + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: package.isDownloaded ? AppColors.primarySurface : AppColors.surfaceVariant, + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + package.isDownloaded ? Icons.check_circle : Icons.map_outlined, + color: package.isDownloaded ? AppColors.primary : AppColors.textSecondary, + size: 24, + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + package.title, + style: AppTypography.titleMedium.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 2), + Text( + package.subtitle, + style: AppTypography.bodySmall.copyWith(color: AppColors.textSecondary), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: AppColors.surfaceVariant, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + '${package.sizeMb} MB', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12), + ), + ), + ], + ), + const SizedBox(height: AppSpacing.sm), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.surfaceVariant.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(6), + ), + child: Row( + children: [ + const Icon(Icons.place_outlined, size: 16, color: AppColors.textSecondary), + const SizedBox(width: 6), + Expanded( + child: Text( + 'Includes ${package.placesCount}+ venues: ${package.coverage}', + style: const TextStyle(fontSize: 11, color: AppColors.textSecondary), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + const SizedBox(height: AppSpacing.md), + + // Progress bar if downloading + if (package.isDownloading) ...[ + LinearProgressIndicator( + value: package.downloadProgress, + backgroundColor: AppColors.surfaceVariant, + color: AppColors.primary, + borderRadius: BorderRadius.circular(4), + ), + const SizedBox(height: 6), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Downloading regional tiles & routes...', + style: TextStyle(fontSize: 11, color: AppColors.textSecondary), + ), + Text( + '${(package.downloadProgress * 100).toInt()}%', + style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: AppColors.primary), + ), + ], + ), + ] else if (package.isDownloaded) ...[ + Row( + children: [ + const Icon(Icons.check_circle, size: 16, color: AppColors.success), + const SizedBox(width: 4), + const Expanded( + child: Text( + 'Saved Offline', + style: TextStyle(color: AppColors.success, fontSize: 12, fontWeight: FontWeight.bold), + overflow: TextOverflow.ellipsis, + ), + ), + TextButton.icon( + onPressed: () => offlineService.deletePackage(package.id), + icon: const Icon(Icons.delete_outline, size: 16, color: AppColors.error), + label: const Text('Remove', style: TextStyle(color: AppColors.error, fontSize: 12)), + style: TextButton.styleFrom( + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + ), + ), + ], + ), + ] else ...[ + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () => offlineService.downloadPackage(package.id), + icon: const Icon(Icons.download, size: 18), + label: Text('Download Region (${package.sizeMb} MB)'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + minimumSize: const Size.fromHeight(40), + ), + ), + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/features/profile/presentation/profile_screen.dart b/lib/features/profile/presentation/profile_screen.dart index 74480e1..27c92e9 100644 --- a/lib/features/profile/presentation/profile_screen.dart +++ b/lib/features/profile/presentation/profile_screen.dart @@ -2,6 +2,9 @@ import 'package:access_map/app/app_state.dart'; import 'package:access_map/core/theme/app_theme.dart'; import 'package:access_map/shared/models/accessibility_need.dart'; import 'package:access_map/shared/models/travel_mode.dart'; +import 'package:access_map/features/profile/presentation/edit_profile_screen.dart'; +import 'package:access_map/features/profile/presentation/disability_pass_screen.dart'; +import 'package:access_map/features/profile/presentation/offline_maps_screen.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -12,34 +15,74 @@ class ProfileScreen extends StatelessWidget { Widget build(BuildContext context) { final state = context.watch(); final profile = state.profile; + return Scaffold( - appBar: AppBar(title: const Text('Profile')), + appBar: AppBar( + title: const Text('Profile & Settings'), + actions: [ + IconButton( + icon: const Icon(Icons.edit_outlined), + tooltip: 'Edit Profile & Emergency Info', + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const EditProfileScreen(), + ), + ), + ), + ], + ), body: ListView( key: const ValueKey('profile-list'), padding: const EdgeInsets.all(AppSpacing.lg), children: [ + // User Card with Name & Quick Edit Card( child: Padding( - padding: const EdgeInsets.all(AppSpacing.xl), - child: Row( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( children: [ - const CircleAvatar( - radius: 30, - backgroundColor: AppColors.primarySurface, - foregroundColor: AppColors.primary, - child: Icon(Icons.person), + Row( + children: [ + const CircleAvatar( + radius: 30, + backgroundColor: AppColors.primarySurface, + foregroundColor: AppColors.primary, + child: Icon(Icons.person, size: 36), + ), + const SizedBox(width: AppSpacing.lg), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + profile.displayName, + style: AppTypography.headlineSmall.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 2), + Text( + '${profile.travelMode.displayName} • ${profile.accessibilityNeeds.isEmpty ? "No specific need selected" : profile.accessibilityNeeds.map((n) => n.displayName).join(", ")}', + style: AppTypography.bodySmall.copyWith( + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ], ), - const SizedBox(width: AppSpacing.lg), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(profile.displayName, style: AppTypography.headlineSmall), - Text( - '${profile.travelMode.displayName} - ${profile.accessibilityNeeds.map((n) => n.displayName).join(', ')}', - style: AppTypography.bodySmall, + const SizedBox(height: AppSpacing.md), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const EditProfileScreen(), ), - ], + ), + icon: const Icon(Icons.edit, size: 18), + label: const Text('Edit Name, Medical Details & ICE Contacts'), ), ), ], @@ -47,7 +90,183 @@ class ProfileScreen extends StatelessWidget { ), ), const SizedBox(height: AppSpacing.xl), - const Text('Travel Mode', style: AppTypography.headlineSmall), + + // Digital Disability Pass & UDID Certificate (1-Tap for Conductors / Security) + Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: AppRadii.borderRadiusLg, + side: const BorderSide(color: AppColors.divider), + ), + clipBehavior: Clip.antiAlias, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md, vertical: AppSpacing.sm + 2), + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [AppColors.primaryDark, AppColors.primary], + ), + ), + child: Row( + children: [ + const Icon(Icons.badge, color: Colors.white, size: 20), + const SizedBox(width: 8), + const Expanded( + child: Text( + 'ACCESSIBILITY ID PASS', + style: TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.bold, + letterSpacing: 0.6, + ), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(12), + ), + child: const Text( + 'DOC ON FILE', + style: TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold), + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + profile.displayName, + style: AppTypography.titleLarge.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 2), + Text( + 'Reg / UDID: ${profile.medicalInfo?.udidNumber.isNotEmpty == true ? profile.medicalInfo!.udidNumber : 'GA0710119950034512'}', + style: TextStyle( + color: AppColors.primaryDark, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + '${profile.medicalInfo?.disabilityCategory.isNotEmpty == true ? profile.medicalInfo!.disabilityCategory : 'Visual Impairment (Low Vision)'} • ${profile.medicalInfo?.disabilityPercentage.isNotEmpty == true ? profile.medicalInfo!.disabilityPercentage : '75% Permanent'}', + style: AppTypography.bodySmall.copyWith(color: AppColors.textSecondary), + ), + const SizedBox(height: AppSpacing.md), + Semantics( + button: true, + label: 'Show Digital Disability Pass and Medical Certificate to official or conductor', + child: ElevatedButton.icon( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const DisabilityPassScreen(), + ), + ), + icon: const Icon(Icons.badge, size: 20), + label: const Text('SHOW ASSISTANCE PASS'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + minimumSize: const Size.fromHeight(48), + textStyle: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13), + ), + ), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: AppSpacing.xl), + + // Emergency Medical Profile Overview + Text('Emergency Medical ID (ICE)', style: AppTypography.headlineSmall), + const SizedBox(height: AppSpacing.sm), + Card( + elevation: 1, + shape: RoundedRectangleBorder( + borderRadius: AppRadii.borderRadiusMd, + side: const BorderSide(color: AppColors.divider), + ), + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.medical_services, color: AppColors.error), + const SizedBox(width: AppSpacing.sm), + Text( + 'Primary Condition: ', + style: AppTypography.titleMedium.copyWith(fontWeight: FontWeight.bold), + ), + Expanded( + child: Text( + (profile.medicalInfo?.condition.isNotEmpty ?? false) + ? profile.medicalInfo!.condition + : 'Not specified yet', + style: AppTypography.bodyMedium, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + const Icon(Icons.warning_amber, color: Colors.orange), + const SizedBox(width: AppSpacing.sm), + Text( + 'Allergies: ', + style: AppTypography.titleMedium.copyWith(fontWeight: FontWeight.bold), + ), + Expanded( + child: Text( + (profile.medicalInfo?.allergies.isNotEmpty ?? false) + ? profile.medicalInfo!.allergies + : 'None recorded', + style: AppTypography.bodyMedium, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + const Icon(Icons.contacts, color: AppColors.primary), + const SizedBox(width: AppSpacing.sm), + Text( + 'Emergency Contacts: ', + style: AppTypography.titleMedium.copyWith(fontWeight: FontWeight.bold), + ), + Text( + '${profile.emergencyContacts.length} contact(s) saved', + style: AppTypography.bodyMedium, + ), + ], + ), + ], + ), + ), + ), + const SizedBox(height: AppSpacing.xl), + + // Travel Mode + Text('Travel Mode', style: AppTypography.headlineSmall), const SizedBox(height: AppSpacing.md), SegmentedButton( segments: TravelMode.values @@ -55,17 +274,21 @@ class ProfileScreen extends StatelessWidget { (mode) => ButtonSegment( value: mode, label: Text(mode.displayName), - icon: Icon(mode == TravelMode.paAssisted ? Icons.handshake : Icons.person_pin_circle), + icon: Icon(mode == TravelMode.paAssisted + ? Icons.handshake + : Icons.person_pin_circle), ), ) .toList(), selected: {profile.travelMode}, onSelectionChanged: (selection) { - state.updateDemoProfile(travelMode: selection.first); + context.read().updateProfile(travelMode: selection.first); }, ), const SizedBox(height: AppSpacing.xl), - const Text('Accessibility Needs', style: AppTypography.headlineSmall), + + // Accessibility Needs + Text('Accessibility Needs', style: AppTypography.headlineSmall), const SizedBox(height: AppSpacing.md), ...AccessibilityNeed.values.map( (need) => CheckboxListTile( @@ -77,6 +300,56 @@ class ProfileScreen extends StatelessWidget { ), ), const SizedBox(height: AppSpacing.xl), + + // Spoken Language (TTS) + Text('Voice Language (TTS)', style: AppTypography.headlineSmall), + const SizedBox(height: AppSpacing.md), + DropdownButtonFormField( + initialValue: state.ttsLanguage, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'Select Spoken Language', + ), + items: const [ + DropdownMenuItem(value: 'en-US', child: Text('English (US/UK)')), + DropdownMenuItem(value: 'hi-IN', child: Text('Hindi (hi-IN)')), + ], + onChanged: (value) { + if (value != null) { + context.read().setTTSLanguage(value); + } + }, + ), + const SizedBox(height: AppSpacing.xl), + + // Offline Regional Maps + Text('Offline Maps & Storage', style: AppTypography.headlineSmall), + const SizedBox(height: AppSpacing.sm), + Card( + shape: RoundedRectangleBorder( + borderRadius: AppRadii.borderRadiusMd, + side: const BorderSide(color: AppColors.divider), + ), + child: ListTile( + leading: const CircleAvatar( + backgroundColor: AppColors.primarySurface, + child: Icon(Icons.download_for_offline, color: AppColors.primary), + ), + title: const Text('Offline Regional Maps', style: TextStyle(fontWeight: FontWeight.bold)), + subtitle: Text( + '${state.offlineMapService.totalDownloadedMb} MB saved • Tap to download regional packs', + ), + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const OfflineMapsScreen(), + ), + ), + ), + ), + const SizedBox(height: AppSpacing.xl), + + // Wheelchair score policy notice Container( padding: const EdgeInsets.all(AppSpacing.lg), decoration: BoxDecoration( @@ -99,8 +372,8 @@ class ProfileScreen extends StatelessWidget { Expanded( child: Text( profile.shouldShowWheelchairScore - ? 'Wheelchair-Friendly scores are visible for this profile.' - : 'Wheelchair-Friendly scores are hidden until physical or wheelchair needs are selected.', + ? 'Wheelchair-Friendly scores are prioritized for your profile.' + : 'Wheelchair-Friendly scores will be highlighted when physical or wheelchair needs are selected.', style: AppTypography.bodyMedium, ), ), @@ -108,17 +381,6 @@ class ProfileScreen extends StatelessWidget { ), ), const SizedBox(height: AppSpacing.lg), - OutlinedButton.icon( - onPressed: () { - final needs = List.from(profile.accessibilityNeeds); - if (!needs.contains(AccessibilityNeed.wheelchairUser)) { - needs.add(AccessibilityNeed.wheelchairUser); - } - context.read().updateDemoProfile(needs: needs); - }, - icon: const Icon(Icons.accessible), - label: const Text('Demo Wheelchair Profile'), - ), ], ), ); diff --git a/lib/features/reviews/presentation/write_review_screen.dart b/lib/features/reviews/presentation/write_review_screen.dart index 9acc104..7e3c72f 100644 --- a/lib/features/reviews/presentation/write_review_screen.dart +++ b/lib/features/reviews/presentation/write_review_screen.dart @@ -128,13 +128,36 @@ class _RatingSlider extends StatelessWidget { Text('${value.round()}/10', style: AppTypography.titleMedium), ], ), - Slider( - min: 1, - max: 10, - divisions: 9, - value: value, - label: value.round().toString(), - onChanged: onChanged, + Semantics( + value: '${value.round()} out of 10', + increasedValue: value < 10 ? '${(value + 1).round()} out of 10' : null, + decreasedValue: value > 1 ? '${(value - 1).round()} out of 10' : null, + onIncrease: value < 10 ? () => onChanged(value + 1) : null, + onDecrease: value > 1 ? () => onChanged(value - 1) : null, + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.remove_circle_outline), + onPressed: value > 1 ? () => onChanged(value - 1) : null, + tooltip: 'Decrease $label rating', + ), + Expanded( + child: Slider( + min: 1, + max: 10, + divisions: 9, + value: value, + label: value.round().toString(), + onChanged: onChanged, + ), + ), + IconButton( + icon: const Icon(Icons.add_circle_outline), + onPressed: value < 10 ? () => onChanged(value + 1) : null, + tooltip: 'Increase $label rating', + ), + ], + ), ), ], ), diff --git a/lib/shared/models/accessibility_need.dart b/lib/shared/models/accessibility_need.dart index 3fc7ce7..aea5e89 100644 --- a/lib/shared/models/accessibility_need.dart +++ b/lib/shared/models/accessibility_need.dart @@ -1,88 +1,59 @@ import 'package:flutter/material.dart'; -/// Extensible accessibility need enum. -/// Only cannotTalk, cannotSpeak, cannotSee are exposed in MVP onboarding. +/// Core accessibility needs — clean, non-overlapping categories. enum AccessibilityNeed { - cannotTalk, - cannotSpeak, - cannotSee, - physicallyDisabled, - deaf, - visuallyImpaired, - wheelchairUser, - other; + wheelchairMobility, + blindLowVision, + deafHardOfHearing, + speechCommunication, + cognitiveNeuro; String get displayName { switch (this) { - case AccessibilityNeed.cannotTalk: - return "Can't Talk"; - case AccessibilityNeed.cannotSpeak: - return "Can't Speak"; - case AccessibilityNeed.cannotSee: - return "Can't See"; - case AccessibilityNeed.physicallyDisabled: - return 'Physically Disabled'; - case AccessibilityNeed.deaf: - return 'Deaf / Hearing Impaired'; - case AccessibilityNeed.visuallyImpaired: - return 'Visually Impaired'; - case AccessibilityNeed.wheelchairUser: - return 'Wheelchair User'; - case AccessibilityNeed.other: - return 'Other'; + case AccessibilityNeed.wheelchairMobility: + return 'Wheelchair / Mobility'; + case AccessibilityNeed.blindLowVision: + return 'Blind / Low Vision'; + case AccessibilityNeed.deafHardOfHearing: + return 'Deaf / Hard of Hearing'; + case AccessibilityNeed.speechCommunication: + return 'Speech / Communication'; + case AccessibilityNeed.cognitiveNeuro: + return 'Cognitive / Neurodiverse'; } } String get description { switch (this) { - case AccessibilityNeed.cannotTalk: - return 'I have difficulty with verbal communication.'; - case AccessibilityNeed.cannotSpeak: - return 'I am unable to speak or have speech impairment.'; - case AccessibilityNeed.cannotSee: - return 'I have visual impairment or blindness.'; - case AccessibilityNeed.physicallyDisabled: - return 'I have physical mobility challenges.'; - case AccessibilityNeed.deaf: - return 'I have hearing impairment or deafness.'; - case AccessibilityNeed.visuallyImpaired: - return 'I have partial visual impairment.'; - case AccessibilityNeed.wheelchairUser: - return 'I use a wheelchair for mobility.'; - case AccessibilityNeed.other: - return 'I have other accessibility needs.'; + case AccessibilityNeed.wheelchairMobility: + return 'I use a wheelchair or have physical mobility challenges.'; + case AccessibilityNeed.blindLowVision: + return 'I am blind or have low vision.'; + case AccessibilityNeed.deafHardOfHearing: + return 'I am deaf or have hearing impairment.'; + case AccessibilityNeed.speechCommunication: + return 'I have difficulty with speech or verbal communication.'; + case AccessibilityNeed.cognitiveNeuro: + return 'I have cognitive, neurological, or learning needs.'; } } IconData get icon { switch (this) { - case AccessibilityNeed.cannotTalk: - return Icons.voice_over_off; - case AccessibilityNeed.cannotSpeak: - return Icons.speaker_notes_off; - case AccessibilityNeed.cannotSee: - return Icons.visibility_off; - case AccessibilityNeed.physicallyDisabled: + case AccessibilityNeed.wheelchairMobility: return Icons.accessible; - case AccessibilityNeed.deaf: + case AccessibilityNeed.blindLowVision: + return Icons.visibility_off; + case AccessibilityNeed.deafHardOfHearing: return Icons.hearing_disabled; - case AccessibilityNeed.visuallyImpaired: - return Icons.remove_red_eye; - case AccessibilityNeed.wheelchairUser: - return Icons.accessible; - case AccessibilityNeed.other: - return Icons.more_horiz; + case AccessibilityNeed.speechCommunication: + return Icons.speaker_notes_off; + case AccessibilityNeed.cognitiveNeuro: + return Icons.psychology; } } - /// The MVP onboarding only shows these three options. - static List get mvpOptions => [ - cannotTalk, - cannotSpeak, - cannotSee, - ]; - /// Whether this need is related to physical/wheelchair accessibility. bool get isPhysicalAccessibility => - this == physicallyDisabled || this == wheelchairUser; + this == wheelchairMobility; } diff --git a/lib/shared/models/hazard_report.dart b/lib/shared/models/hazard_report.dart new file mode 100644 index 0000000..9ab70d1 --- /dev/null +++ b/lib/shared/models/hazard_report.dart @@ -0,0 +1,121 @@ +import 'package:flutter/material.dart'; + +enum HazardCategory { + rampBlocked, + liftBroken, + unexpectedStairs, + sidewalkBlocked, + unsafeCrossing, + constructionObstruction, + waterlogging, + other, +} + +extension HazardCategoryExtension on HazardCategory { + String get label { + switch (this) { + case HazardCategory.rampBlocked: + return 'Ramp Blocked'; + case HazardCategory.liftBroken: + return 'Broken Elevator / Lift'; + case HazardCategory.unexpectedStairs: + return 'Unexpected Stairs'; + case HazardCategory.sidewalkBlocked: + return 'Sidewalk Blocked'; + case HazardCategory.unsafeCrossing: + return 'Unsafe Road Crossing'; + case HazardCategory.constructionObstruction: + return 'Construction Obstruction'; + case HazardCategory.waterlogging: + return 'Waterlogging / Potholes'; + case HazardCategory.other: + return 'Accessibility Hazard'; + } + } + + IconData get icon { + switch (this) { + case HazardCategory.rampBlocked: + return Icons.block; + case HazardCategory.liftBroken: + return Icons.elevator; + case HazardCategory.unexpectedStairs: + return Icons.stairs; + case HazardCategory.sidewalkBlocked: + return Icons.do_not_disturb_on; + case HazardCategory.unsafeCrossing: + return Icons.warning_amber_rounded; + case HazardCategory.constructionObstruction: + return Icons.construction; + case HazardCategory.waterlogging: + return Icons.water_drop; + case HazardCategory.other: + return Icons.report_problem; + } + } + + Color get color { + switch (this) { + case HazardCategory.rampBlocked: + case HazardCategory.unexpectedStairs: + return const Color(0xFFDC2626); // Red + case HazardCategory.liftBroken: + case HazardCategory.constructionObstruction: + return const Color(0xFFEA580C); // Deep Orange + case HazardCategory.waterlogging: + return const Color(0xFF0284C7); // Sky blue + default: + return const Color(0xFFD97706); // Amber + } + } +} + +class HazardReport { + final String id; + final HazardCategory category; + final String description; + final double latitude; + final double longitude; + final DateTime reportedAt; + DateTime lastConfirmedAt; + int confirmationCount; + int disputeCount; + bool isResolved; + + HazardReport({ + required this.id, + required this.category, + required this.description, + required this.latitude, + required this.longitude, + DateTime? reportedAt, + DateTime? lastConfirmedAt, + this.confirmationCount = 1, + this.disputeCount = 0, + this.isResolved = false, + }) : reportedAt = reportedAt ?? DateTime.now(), + lastConfirmedAt = lastConfirmedAt ?? (reportedAt ?? DateTime.now()); + + Duration get age => DateTime.now().difference(lastConfirmedAt); + + String get ageLabel { + if (age.inMinutes < 1) return 'just now'; + if (age.inMinutes < 60) return '${age.inMinutes}m ago'; + if (age.inHours < 24) return '${age.inHours}h ago'; + return '${age.inDays}d ago'; + } + + void confirmStillPresent() { + confirmationCount += 1; + lastConfirmedAt = DateTime.now(); + } + + void markFixed() { + isResolved = true; + lastConfirmedAt = DateTime.now(); + } + + void dispute() { + disputeCount += 1; + } +} diff --git a/lib/shared/models/map_style.dart b/lib/shared/models/map_style.dart new file mode 100644 index 0000000..359a709 --- /dev/null +++ b/lib/shared/models/map_style.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; + +enum MapStyle { + standard, + satellite, + terrain3d, + highContrastDark; + + String get displayName { + switch (this) { + case MapStyle.standard: + return 'Standard Street'; + case MapStyle.satellite: + return 'Satellite Aerial'; + case MapStyle.terrain3d: + return '3D Topo & Incline Slopes'; + case MapStyle.highContrastDark: + return 'Dark High-Contrast'; + } + } + + String get description { + switch (this) { + case MapStyle.standard: + return 'Clear accessible roads, footpaths, and verified landmarks'; + case MapStyle.satellite: + return 'Photorealistic aerial imagery showing actual sidewalks & curb cuts'; + case MapStyle.terrain3d: + return 'Topographical contours & terrain gradients for wheelchair slopes'; + case MapStyle.highContrastDark: + return 'High-visibility dark theme optimized for low-vision accessibility'; + } + } + + IconData get icon { + switch (this) { + case MapStyle.standard: + return Icons.map; + case MapStyle.satellite: + return Icons.satellite_alt; + case MapStyle.terrain3d: + return Icons.terrain; + case MapStyle.highContrastDark: + return Icons.dark_mode; + } + } + + String get urlTemplate { + switch (this) { + case MapStyle.standard: + return 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; + case MapStyle.satellite: + return 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'; + case MapStyle.terrain3d: + return 'https://tile.opentopomap.org/{z}/{x}/{y}.png'; + case MapStyle.highContrastDark: + return 'https://cartodb-basemaps-a.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png'; + } + } +} diff --git a/lib/shared/models/place.dart b/lib/shared/models/place.dart index a20b200..d0458e2 100644 --- a/lib/shared/models/place.dart +++ b/lib/shared/models/place.dart @@ -1,6 +1,7 @@ import 'accessibility_feature.dart'; import 'place_category.dart'; import 'place_review.dart'; +import 'travel_mode.dart'; import 'dart:math' as math; /// A place with accessibility data, scores, features, and reviews. @@ -75,6 +76,19 @@ class Place { .where((f) => f.status == FeatureStatus.unknown) .toList(); + bool hasConfirmedFeatureMatching(String keyword) { + return availableFeatures.any((f) => f.name.toLowerCase().contains(keyword.toLowerCase())); + } + + bool get hasConfirmedStepFreeAccess => + hasConfirmedFeatureMatching('step-free') || hasConfirmedFeatureMatching('ramp'); + + bool get hasConfirmedStaffAssistance => + hasConfirmedFeatureMatching('staff assistance'); + + bool suitableForMode(TravelMode mode) => + mode == TravelMode.solo ? hasConfirmedStepFreeAccess : (hasConfirmedStepFreeAccess || hasConfirmedStaffAssistance); + /// Distance in kilometres from the given coordinates. double distanceKmFrom(double lat, double lng) { const r = 6371.0; // Earth radius in km. diff --git a/lib/shared/models/place_category.dart b/lib/shared/models/place_category.dart index c54d0d1..56edb0a 100644 --- a/lib/shared/models/place_category.dart +++ b/lib/shared/models/place_category.dart @@ -13,7 +13,10 @@ enum PlaceCategory { hotel, touristAttraction, communityCenter, - repairSupport; + repairSupport, + busStop, + pharmacy, + atm; String get displayName { switch (this) { @@ -41,6 +44,12 @@ enum PlaceCategory { return 'Community Center'; case PlaceCategory.repairSupport: return 'Repair & Support'; + case PlaceCategory.busStop: + return 'Bus Stop'; + case PlaceCategory.pharmacy: + return 'Pharmacy'; + case PlaceCategory.atm: + return 'ATM'; } } @@ -70,6 +79,12 @@ enum PlaceCategory { return Icons.groups; case PlaceCategory.repairSupport: return Icons.build; + case PlaceCategory.busStop: + return Icons.directions_bus; + case PlaceCategory.pharmacy: + return Icons.local_pharmacy; + case PlaceCategory.atm: + return Icons.local_atm; } } } diff --git a/lib/shared/models/travel_mode.dart b/lib/shared/models/travel_mode.dart index 03f425b..ce798d5 100644 --- a/lib/shared/models/travel_mode.dart +++ b/lib/shared/models/travel_mode.dart @@ -1,3 +1,5 @@ +import 'package:flutter/material.dart'; + /// Travel mode — independent of accessibility needs. enum TravelMode { solo, @@ -21,12 +23,14 @@ enum TravelMode { } } - String get iconLabel { + IconData get icon { switch (this) { case TravelMode.solo: - return '🧑'; + return Icons.person; case TravelMode.paAssisted: - return '🤝'; + return Icons.handshake; } } + + String get iconLabel => displayName; } diff --git a/lib/shared/models/user_profile.dart b/lib/shared/models/user_profile.dart index 78bc3dc..76ebdb2 100644 --- a/lib/shared/models/user_profile.dart +++ b/lib/shared/models/user_profile.dart @@ -1,6 +1,74 @@ import 'accessibility_need.dart'; import 'travel_mode.dart'; +class MedicalInfo { + final String condition; + final String allergies; + final String instructions; + final String udidNumber; + final String disabilityCategory; + final String disabilityPercentage; + final String issuingAuthority; + final String issueDate; + final String bloodGroup; + final bool hasUploadedDocument; + final String documentName; + + const MedicalInfo({ + this.condition = '', + this.allergies = '', + this.instructions = '', + this.udidNumber = '', + this.disabilityCategory = '', + this.disabilityPercentage = '', + this.issuingAuthority = '', + this.issueDate = '', + this.bloodGroup = '', + this.hasUploadedDocument = false, + this.documentName = '', + }); + + MedicalInfo copyWith({ + String? condition, + String? allergies, + String? instructions, + String? udidNumber, + String? disabilityCategory, + String? disabilityPercentage, + String? issuingAuthority, + String? issueDate, + String? bloodGroup, + bool? hasUploadedDocument, + String? documentName, + }) { + return MedicalInfo( + condition: condition ?? this.condition, + allergies: allergies ?? this.allergies, + instructions: instructions ?? this.instructions, + udidNumber: udidNumber ?? this.udidNumber, + disabilityCategory: disabilityCategory ?? this.disabilityCategory, + disabilityPercentage: disabilityPercentage ?? this.disabilityPercentage, + issuingAuthority: issuingAuthority ?? this.issuingAuthority, + issueDate: issueDate ?? this.issueDate, + bloodGroup: bloodGroup ?? this.bloodGroup, + hasUploadedDocument: hasUploadedDocument ?? this.hasUploadedDocument, + documentName: documentName ?? this.documentName, + ); + } +} + +class EmergencyContact { + final String name; + final String phone; + final String relation; + + const EmergencyContact({ + required this.name, + required this.phone, + required this.relation, + }); +} + /// Activity type for community contributions. enum ContributionType { review, @@ -61,7 +129,7 @@ class CommunityContribution { }); } -/// User profile — works with mock user now, real auth later. +/// User profile for the current session. class UserProfile { final String id; final String displayName; @@ -73,6 +141,8 @@ class UserProfile { final int photoCount; final List recentActivity; final bool onboardingComplete; + final MedicalInfo? medicalInfo; + final List emergencyContacts; const UserProfile({ required this.id, @@ -85,6 +155,8 @@ class UserProfile { this.photoCount = 0, this.recentActivity = const [], this.onboardingComplete = false, + this.medicalInfo, + this.emergencyContacts = const [], }); /// Check if the user's profile warrants showing wheelchair-specific score. @@ -102,6 +174,8 @@ class UserProfile { int? photoCount, List? recentActivity, bool? onboardingComplete, + MedicalInfo? medicalInfo, + List? emergencyContacts, }) { return UserProfile( id: id, @@ -114,43 +188,70 @@ class UserProfile { photoCount: photoCount ?? this.photoCount, recentActivity: recentActivity ?? this.recentActivity, onboardingComplete: onboardingComplete ?? this.onboardingComplete, + medicalInfo: medicalInfo ?? this.medicalInfo, + emergencyContacts: emergencyContacts ?? this.emergencyContacts, ); } - /// Default mock user for development. - static UserProfile mockUser() => UserProfile( - id: 'demo-user', - displayName: 'Demo User', + /// Default profile with initial realistic demo data + static UserProfile empty() => UserProfile( + id: 'user-1', + displayName: 'Aarav Sharma', travelMode: TravelMode.solo, - accessibilityNeeds: [AccessibilityNeed.cannotSee], - communityPoints: 245, - reviewCount: 12, + accessibilityNeeds: const [], + communityPoints: 125, + reviewCount: 3, accessibilityUpdates: 8, - photoCount: 4, + photoCount: 2, + medicalInfo: const MedicalInfo( + condition: 'Low vision / requires audio guidance & high contrast', + allergies: 'Penicillin', + instructions: 'Carries white cane. Guide by offering arm.', + udidNumber: 'GA0710119950034512', + disabilityCategory: 'Visual Impairment (Low Vision)', + disabilityPercentage: '75% Permanent', + issuingAuthority: 'Goa Medical College (GMC) & Hospital, Bambolim', + issueDate: '12/03/2023', + bloodGroup: 'B+ Positive', + hasUploadedDocument: true, + documentName: 'Govt_UDID_Certificate_Aarav.pdf', + ), + emergencyContacts: const [ + EmergencyContact(name: 'Raj Sharma', phone: '+919876543210', relation: 'Brother'), + EmergencyContact(name: 'Dr. Anita Desai', phone: '+919822112233', relation: 'Physician'), + ], recentActivity: [ CommunityContribution( - id: 'c1', - type: ContributionType.review, - description: 'Reviewed Fishka Restaurant', + id: 'c-1', + type: ContributionType.accessibilityUpdate, + description: 'Confirmed step-free entrance & ramp at Panaji Bus Stand', pointsEarned: 5, timestamp: DateTime.now().subtract(const Duration(hours: 2)), - placeName: 'Fishka Restaurant', + placeName: 'Panaji Bus Stand', ), CommunityContribution( - id: 'c2', - type: ContributionType.accessibilityUpdate, - description: 'Added ramp information at Basilica of Bom Jesus', - pointsEarned: 5, + id: 'c-2', + type: ContributionType.review, + description: 'Added detailed sensory & accessibility review for Fishka Restaurant', + pointsEarned: 10, timestamp: DateTime.now().subtract(const Duration(days: 1)), - placeName: 'Basilica of Bom Jesus', + placeName: 'Fishka Restaurant', ), CommunityContribution( - id: 'c3', + id: 'c-3', type: ContributionType.photoUpload, - description: 'Added accessibility photo at Miramar Beach', + description: 'Uploaded tactile paving & accessible entrance photo at Goa Medical College', pointsEarned: 10, - timestamp: DateTime.now().subtract(const Duration(days: 3)), - placeName: 'Miramar Beach', + timestamp: DateTime.now().subtract(const Duration(days: 2)), + placeName: 'Goa Medical College', + ), + CommunityContribution( + id: 'c-4', + type: ContributionType.confirmation, + description: 'Verified audible elevator signals at Mall de Goa', + pointsEarned: 3, + timestamp: DateTime.now().subtract(const Duration(days: 4)), + placeName: 'Mall de Goa', ), ], ); diff --git a/lib/shared/widgets/access_search_bar.dart b/lib/shared/widgets/access_search_bar.dart index 520b135..9414687 100644 --- a/lib/shared/widgets/access_search_bar.dart +++ b/lib/shared/widgets/access_search_bar.dart @@ -32,29 +32,47 @@ class _AccessSearchBarState extends State { _controller.text = state.searchQuery; _controller.selection = TextSelection.collapsed(offset: _controller.text.length); } - return Material( - color: AppColors.surface, - borderRadius: AppRadii.borderRadiusMd, - elevation: 4, - shadowColor: Colors.black.withValues(alpha: 0.12), + return Container( + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: AppRadii.borderRadiusMd, + border: Border.all(color: AppColors.border, width: 2), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.15), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + ), child: TextField( controller: _controller, onChanged: context.read().search, textInputAction: TextInputAction.search, + style: AppTypography.bodyLarge.copyWith(fontWeight: FontWeight.w600), decoration: InputDecoration( hintText: 'Search accessible places...', - prefixIcon: const Icon(Icons.search), + hintStyle: AppTypography.bodyLarge.copyWith(color: AppColors.textSecondary), + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric(vertical: 16), + prefixIcon: const Icon(Icons.search, color: AppColors.primary, size: 28), suffixIcon: IconButton( tooltip: 'Voice search', onPressed: () async { - final message = await context.read().startVoiceSearch(); + final appState = context.read(); + final message = await appState.startVoiceSearch(); if (message != null && context.mounted) { + appState.ttsService.speak(message); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(message)), ); } }, - icon: Icon(state.isListening ? Icons.mic : Icons.mic_none), + icon: Icon( + state.isListening ? Icons.mic : Icons.mic_none, + color: state.isListening ? AppColors.error : AppColors.primary, + size: 28, + ), ), ), ), diff --git a/lib/shared/widgets/app_components.dart b/lib/shared/widgets/app_components.dart index 8c22a81..977e3ae 100644 --- a/lib/shared/widgets/app_components.dart +++ b/lib/shared/widgets/app_components.dart @@ -44,34 +44,37 @@ class ScoreCard extends StatelessWidget { @override Widget build(BuildContext context) { final color = AppColors.scoreColor(score); - return Container( - padding: const EdgeInsets.all(AppSpacing.lg), - decoration: BoxDecoration( - color: prominent ? color.withValues(alpha: 0.10) : AppColors.surface, - borderRadius: AppRadii.borderRadiusMd, - border: Border.all(color: prominent ? color : AppColors.divider), - ), - child: Row( - children: [ - CircleAvatar( - backgroundColor: color.withValues(alpha: 0.12), - foregroundColor: color, - child: Icon(icon), - ), - const SizedBox(width: AppSpacing.md), - Expanded( - child: Text( - label, - style: AppTypography.titleMedium, - overflow: TextOverflow.ellipsis, + return Semantics( + label: '$label: ${score.toStringAsFixed(1)} out of 10', + child: Container( + padding: const EdgeInsets.all(AppSpacing.lg), + decoration: BoxDecoration( + color: prominent ? color.withValues(alpha: 0.10) : AppColors.surface, + borderRadius: AppRadii.borderRadiusMd, + border: Border.all(color: prominent ? color : AppColors.divider), + ), + child: Row( + children: [ + CircleAvatar( + backgroundColor: color.withValues(alpha: 0.12), + foregroundColor: color, + child: Icon(icon), ), - ), - Text( - score.toStringAsFixed(1), - style: AppTypography.scoreSmall.copyWith(color: color), - ), - const Text('/10', style: AppTypography.bodySmall), - ], + const SizedBox(width: AppSpacing.md), + Expanded( + child: Text( + label, + style: AppTypography.titleMedium, + overflow: TextOverflow.ellipsis, + ), + ), + Text( + score.toStringAsFixed(1), + style: AppTypography.scoreSmall.copyWith(color: color), + ), + Text('/10', style: AppTypography.bodySmall), + ], + ), ), ); } diff --git a/pubspec.lock b/pubspec.lock index ae2106f..0650b0d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" async: dependency: transitive description: @@ -21,10 +29,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" clock: dependency: transitive description: @@ -33,6 +41,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: cfd4f5f575a49c5f10ca856e9846073f1e6c3ee94912377eea5f6cefc5272941 + url: "https://pub.dev" + source: hosted + version: "2.0.0" collection: dependency: transitive description: @@ -65,6 +81,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" + device_info_plus: + dependency: transitive + description: + name: device_info_plus + sha256: "0891702f96b2e465fe567b7ec448380e6b1c14f60af552a8536d9f583b6b8442" + url: "https://pub.dev" + source: hosted + version: "13.2.0" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: "04b173a92e2d9161dfead145667037c8d834db725ce2e7b942bfe18fd2f45a46" + url: "https://pub.dev" + source: hosted + version: "8.1.0" fake_async: dependency: transitive description: @@ -81,6 +113,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + ffi_leak_tracker: + dependency: transitive + description: + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" + url: "https://pub.dev" + source: hosted + version: "0.1.2" file: dependency: transitive description: @@ -102,6 +142,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_compass: + dependency: "direct main" + description: + name: flutter_compass + sha256: "1b4d7e6c95a675ec8482b5c9c9ccf1ebf0ced3dbec59dce28ad609da953de850" + url: "https://pub.dev" + source: hosted + version: "0.8.1" flutter_lints: dependency: "direct dev" description: @@ -123,11 +171,51 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_tts: + dependency: "direct main" + description: + name: flutter_tts + sha256: cbb3fd43b946e62398560235469e6113e4fe26c40eab1b7cb5e7c417503fb3a8 + url: "https://pub.dev" + source: hosted + version: "3.8.5" flutter_web_plugins: dependency: transitive description: flutter source: sdk version: "0.0.0" + geocoding: + dependency: "direct main" + description: + name: geocoding + sha256: "40f845e0a5505d4b47da991057f2ab25cd1c68bc4570255c7d4cfbb03a952063" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + geocoding_android: + dependency: transitive + description: + name: geocoding_android + sha256: "17e88a69bb0e1ae44597d70df2d2fbdb2f503672cf665421a16ab56ab97e4ead" + url: "https://pub.dev" + source: hosted + version: "5.1.0" + geocoding_darwin: + dependency: transitive + description: + name: geocoding_darwin + sha256: "97552aa24f1453defc17e4d8e40b13ddf097e669c2edfa17ed86349cacdbc1ae" + url: "https://pub.dev" + source: hosted + version: "1.0.3" + geocoding_platform_interface: + dependency: transitive + description: + name: geocoding_platform_interface + sha256: "5164b8871705af7573698b3aa0746337faf818d72b488988741bcc7aeedd2f6e" + url: "https://pub.dev" + source: hosted + version: "5.0.0" geolocator: dependency: "direct main" description: @@ -184,6 +272,22 @@ packages: url: "https://pub.dev" source: hosted version: "14.8.1" + google_fonts: + dependency: "direct main" + description: + name: google_fonts + sha256: e3cb3ee6b47fd2472c23de6da5744796a4da195137759ddb3fbcc9467b7b3c7d + url: "https://pub.dev" + source: hosted + version: "8.2.1" + hooks: + dependency: transitive + description: + name: hooks + sha256: eaac480a35ec0814146c2c48d96aaa829e0e44a7662c88ae84c9edf4bc35651f + url: "https://pub.dev" + source: hosted + version: "2.2.0" http: dependency: transitive description: @@ -208,6 +312,30 @@ packages: url: "https://pub.dev" source: hosted version: "0.20.3" + jni: + dependency: transitive + description: + name: jni + sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: b2310cdd4c18c65c081ab141a41efa94aa26c65431803703ece51996f174f351 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.dev" + source: hosted + version: "1.0.0" json_annotation: dependency: transitive description: @@ -284,26 +412,26 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.20" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.19.0" mgrs_dart: dependency: transitive description: @@ -320,6 +448,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: ad56fd53a78ff6b1472fa59ff2a4e8b8ccabafc586fc263a1dfad0b99b5553e3 + url: "https://pub.dev" + source: hosted + version: "9.6.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d + url: "https://pub.dev" + source: hosted + version: "3.0.0" path: dependency: transitive description: @@ -328,6 +472,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" path_provider_linux: dependency: transitive description: @@ -392,6 +560,38 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.5+1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + qr: + dependency: transitive + description: + name: qr + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + qr_flutter: + dependency: "direct main" + description: + name: qr_flutter + sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097" + url: "https://pub.dev" + source: hosted + version: "4.1.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "1cb8564af8d43b464294411db9217f5ec04891c6f22ee2c32d73ae05e88a6bd2" + url: "https://pub.dev" + source: hosted + version: "1.1.1" shared_preferences: dependency: "direct main" description: @@ -521,10 +721,10 @@ packages: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.12" typed_data: dependency: transitive description: @@ -614,13 +814,29 @@ packages: source: hosted version: "4.6.0" vector_math: - dependency: transitive + dependency: "direct main" description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.2" + vibration: + dependency: "direct main" + description: + name: vibration + sha256: "3424f7856b971a2ce94d22be3a3abfb5b9b5984562fdedd621f4e67574be7254" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + vibration_platform_interface: + dependency: transitive + description: + name: vibration_platform_interface + sha256: "258c273268f8aa40c88d29741137c536874a738779b92ddb8aa32ed093721ec5" + url: "https://pub.dev" + source: hosted + version: "0.1.2" vm_service: dependency: transitive description: @@ -637,6 +853,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d + url: "https://pub.dev" + source: hosted + version: "6.4.0" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "73b1d78920a9d6e03f8b4e43e612b87bf3152a0e5c5e5150267762b7c4116904" + url: "https://pub.dev" + source: hosted + version: "3.0.3" wkt_parser: dependency: transitive description: @@ -653,6 +885,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea + url: "https://pub.dev" + source: hosted + version: "3.1.4" sdks: - dart: ">=3.9.2 <4.0.0" - flutter: ">=3.35.0" + dart: ">=3.12.0 <4.0.0" + flutter: ">=3.44.0" diff --git a/pubspec.yaml b/pubspec.yaml index e7e1423..8a88756 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,10 +15,17 @@ dependencies: latlong2: ^0.9.1 go_router: ^14.8.1 speech_to_text: ^7.0.0 + flutter_tts: ^3.8.3 shared_preferences: ^2.3.4 url_launcher: ^6.3.1 geolocator: ^13.0.2 intl: ^0.20.2 + flutter_compass: ^0.8.1 + vibration: ^3.2.1 + geocoding: ^5.0.0 + vector_math: ^2.4.2 + google_fonts: ^8.2.1 + qr_flutter: ^4.1.0 dev_dependencies: flutter_test: diff --git a/test/acceptance_flow_test.dart b/test/acceptance_flow_test.dart index 7988465..24ab854 100644 --- a/test/acceptance_flow_test.dart +++ b/test/acceptance_flow_test.dart @@ -1,7 +1,7 @@ import 'package:access_map/app/access_map_app.dart'; import 'package:access_map/app/app_state.dart'; import 'package:access_map/shared/models/accessibility_need.dart'; -import 'package:access_map/shared/models/travel_mode.dart'; +import 'package:access_map/shared/widgets/app_components.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:provider/provider.dart'; @@ -9,46 +9,38 @@ import 'package:provider/provider.dart'; /// Pumps the app, flushing the 300ms mock repository delay so no /// timers are left pending at the end of a test. Future pumpApp(WidgetTester tester) async { + tester.view.physicalSize = const Size(1080, 1920); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget(const AccessMapApp()); await tester.pumpAndSettle(); - await tester.pump(const Duration(milliseconds: 400)); + await tester.pump(const Duration(milliseconds: 500)); } -/// Completes onboarding (PA Assisted + Can't See) and returns to the map. +/// Completes onboarding (Blind / Low Vision) and enters the map. Future completeOnboarding(WidgetTester tester) async { await pumpApp(tester); - await tester.tap(find.text('PA Assisted')); - await tester.pump(); - await tester.tap(find.text('Continue')); + final needFinder = find.text('Blind / Low Vision'); + await tester.ensureVisible(needFinder); await tester.pumpAndSettle(); - - await tester.tap(find.text("Can't See")); + await tester.tap(needFinder); await tester.pump(); - await tester.ensureVisible(find.text('Open Map')); - await tester.pump(); - await tester.tap(find.text('Open Map')); + + final ctaFinder = find.text('GET STARTED & EXPLORE MAP'); + await tester.ensureVisible(ctaFinder); await tester.pumpAndSettle(); - await tester.pump(const Duration(milliseconds: 400)); + await tester.tap(ctaFinder); + await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 500)); return tester.element(find.byType(MaterialApp)).read(); } const _detailsList = ValueKey('place-details-list'); const _reviewList = ValueKey('write-review-list'); -const _needsScroll = ValueKey('need-selection-scroll'); - -/// Drags the need-selection scroll view until [finder] finds a widget. -Future scrollUntilVisibleNeeds( - WidgetTester tester, - Finder finder, -) async { - for (var i = 0; i < 40 && finder.evaluate().isEmpty; i++) { - await tester.drag(find.byKey(_needsScroll), const Offset(0, -250)); - await tester.pump(); - } - await tester.pumpAndSettle(); -} /// Drags the keyed list until [finder] finds at least one widget. /// Tolerates multiple matches (unlike dragUntilVisible's .single check). @@ -62,60 +54,34 @@ Future scrollUntilVisible( await tester.pump(); } await tester.pumpAndSettle(); + if (finder.evaluate().isNotEmpty) { + await tester.ensureVisible(finder.first); + await tester.pumpAndSettle(); + } } void main() { - testWidgets('cannot proceed to needs without selecting a mode', (tester) async { + testWidgets('welcome onboarding shows brand, core capabilities, and open map CTA', (tester) async { await pumpApp(tester); - final continueBtn = tester.widget( - find.ancestor( - of: find.text('Continue'), - matching: find.bySubtype(), - ), - ); - expect(continueBtn.onPressed, isNull); + expect(find.text('AccessSetu'), findsOneWidget); + expect(find.text('GET STARTED & EXPLORE MAP'), findsOneWidget); + expect(find.text('Accessible Discovery & Navigation'), findsOneWidget); }); - testWidgets('need selection shows every accessibility option with description', (tester) async { + testWidgets('welcome onboarding shows accessibility focus options', (tester) async { await pumpApp(tester); - await tester.tap(find.text('PA Assisted')); - await tester.pump(); - await tester.tap(find.text('Continue')); - await tester.pumpAndSettle(); - - // Every option from the profile page must appear on onboarding. for (final need in AccessibilityNeed.values) { - await scrollUntilVisibleNeeds(tester, find.text(need.displayName)); expect(find.text(need.displayName), findsOneWidget); - expect(find.text(need.description), findsOneWidget); } }); - testWidgets('cannot open map without selecting a need', (tester) async { - await pumpApp(tester); - - await tester.tap(find.text('PA Assisted')); - await tester.pump(); - await tester.tap(find.text('Continue')); - await tester.pumpAndSettle(); - - final openMap = tester.widget( - find.ancestor( - of: find.text('Open Map'), - matching: find.bySubtype(), - ), - ); - expect(openMap.onPressed, isNull); - }); - - testWidgets('mode and need persist into profile after onboarding', (tester) async { + testWidgets('need persists into profile after onboarding', (tester) async { final state = await completeOnboarding(tester); expect(state.profile.onboardingComplete, isTrue); - expect(state.profile.travelMode, TravelMode.paAssisted); - expect(state.profile.accessibilityNeeds, isNotEmpty); + expect(state.profile.accessibilityNeeds.contains(AccessibilityNeed.blindLowVision), isTrue); }); testWidgets('map shell shows places, search and tabs after onboarding', (tester) async { @@ -124,15 +90,18 @@ void main() { expect(find.text('Search accessible places...'), findsOneWidget); expect(find.text('Map'), findsWidgets); expect(find.text('Discover'), findsOneWidget); - expect(find.text('Contribute'), findsOneWidget); + expect(find.text('Community'), findsOneWidget); expect(find.text('Profile'), findsWidgets); - // Demo place data is visible. - expect(find.text('Fishka Restaurant'), findsWidgets); }); testWidgets('wheelchair score hidden for visual profile, shown after profile change', (tester) async { await completeOnboarding(tester); + // Switch to Discover tab to browse places + await tester.tap(find.byIcon(Icons.explore_outlined)); + await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 500)); + // Open the first place details. await tester.tap(find.text('Fishka Restaurant').first); await tester.pumpAndSettle(); @@ -148,10 +117,10 @@ void main() { await tester.pumpAndSettle(); await scrollUntilVisible( tester, - find.text('Demo Wheelchair Profile'), + find.text('Wheelchair / Mobility'), const ValueKey('profile-list'), ); - await tester.tap(find.text('Demo Wheelchair Profile')); + await tester.tap(find.text('Wheelchair / Mobility')); await tester.pumpAndSettle(); final state = tester.element(find.byType(MaterialApp)).read(); @@ -163,13 +132,14 @@ void main() { await tester.enterText(find.byType(TextField).first, 'fishka'); await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 500)); await tester.tap(find.text('Fishka Restaurant').first); await tester.pumpAndSettle(); expect(find.text('Friendly Score'), findsOneWidget); expect(find.text('Accessibility overview'), findsOneWidget); - expect(find.byIcon(Icons.directions), findsOneWidget); + expect(find.byType(PrimaryButton), findsOneWidget); await scrollUntilVisible(tester, find.text('Accessibility features'), _detailsList); expect(find.text('Accessibility features'), findsOneWidget); await scrollUntilVisible(tester, find.text('Reviews'), _detailsList); @@ -180,7 +150,11 @@ void main() { final state = await completeOnboarding(tester); final pointsBefore = state.profile.communityPoints; - // Open place details and start a review. + // Switch to Discover tab and open place details + await tester.tap(find.byIcon(Icons.explore_outlined)); + await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 500)); + await tester.tap(find.text('Fishka Restaurant').first); await tester.pumpAndSettle(); await scrollUntilVisible(tester, find.text('Write'), _detailsList); @@ -197,7 +171,7 @@ void main() { await scrollUntilVisible(tester, find.text('Submit Review'), _reviewList); await tester.tap(find.text('Submit Review')); await tester.pump(); - await tester.pump(const Duration(milliseconds: 400)); + await tester.pump(const Duration(milliseconds: 500)); await tester.pumpAndSettle(); final stateAfter = @@ -210,25 +184,31 @@ void main() { testWidgets('helpful vote works from details', (tester) async { await completeOnboarding(tester); + // Switch to Discover tab + await tester.tap(find.byIcon(Icons.explore_outlined)); + await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 500)); + await tester.tap(find.text('Fishka Restaurant').first); await tester.pumpAndSettle(); await scrollUntilVisible(tester, find.textContaining('Helpful'), _detailsList); + await tester.drag(find.byKey(_detailsList), const Offset(0, -250)); + await tester.pumpAndSettle(); - final votesBefore = tester + final placeBefore = tester .element(find.byType(MaterialApp)) .read() .places - .first - .reviews - .first - .helpfulVotes; + .firstWhere((p) => p.name == 'Fishka Restaurant'); + final votesBefore = placeBefore.reviews.first.helpfulVotes; await tester.tap(find.textContaining('Helpful').first); await tester.pumpAndSettle(); final state = tester.element(find.byType(MaterialApp)).read(); + final placeAfter = state.places.firstWhere((p) => p.name == 'Fishka Restaurant'); expect( - state.places.first.reviews.first.helpfulVotes, + placeAfter.reviews.first.helpfulVotes, votesBefore + 1, ); }); @@ -236,6 +216,11 @@ void main() { testWidgets('feature confirmation awards +3 points', (tester) async { final state = await completeOnboarding(tester); + // Switch to Discover tab + await tester.tap(find.byIcon(Icons.explore_outlined)); + await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 500)); + await tester.tap(find.text('Fishka Restaurant').first); await tester.pumpAndSettle(); diff --git a/test/geo_test.dart b/test/geo_test.dart new file mode 100644 index 0000000..7f452d6 --- /dev/null +++ b/test/geo_test.dart @@ -0,0 +1,14 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:geocoding/geocoding.dart'; + +void main() { + test('Placemark model sanity test', () { + const placemark = Placemark( + name: 'Panaji Church', + street: 'Church Square', + locality: 'Panaji', + ); + expect(placemark.name, 'Panaji Church'); + expect(placemark.street, 'Church Square'); + }); +} diff --git a/test/models_test.dart b/test/models_test.dart index 27c811a..f185c43 100644 --- a/test/models_test.dart +++ b/test/models_test.dart @@ -8,41 +8,83 @@ import 'package:access_map/shared/models/place_review.dart'; import 'package:access_map/shared/models/travel_mode.dart'; import 'package:access_map/shared/models/user_profile.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/material.dart'; +import 'package:access_map/shared/models/accessibility_feature.dart'; void main() { + group('Place mode suitability', () { + test('suitableForMode logic for Solo vs PA', () { + final placeWithStepFree = Place( + id: '1', name: 'A', category: PlaceCategory.cafe, address: '', description: '', + latitude: 0, longitude: 0, friendlyScore: 0, wheelchairScore: 0, visualAccessibilityScore: 0, hearingAccessibilityScore: 0, communicationScore: 0, + reviews: const [], + accessibilityFeatures: const [ + AccessibilityFeature(id: 'f1', name: 'Step-free entrance', category: AccessibilityCategory.physical, icon: Icons.accessible, status: FeatureStatus.available), + ], + ); + + final placeWithStaffAssistance = Place( + id: '2', name: 'B', category: PlaceCategory.cafe, address: '', description: '', + latitude: 0, longitude: 0, friendlyScore: 0, wheelchairScore: 0, visualAccessibilityScore: 0, hearingAccessibilityScore: 0, communicationScore: 0, + reviews: const [], + accessibilityFeatures: const [ + AccessibilityFeature(id: 'f2', name: 'Staff assistance available', category: AccessibilityCategory.physical, icon: Icons.accessible, status: FeatureStatus.available), + ], + ); + + // Step-free is suitable for both + expect(placeWithStepFree.suitableForMode(TravelMode.solo), isTrue); + expect(placeWithStepFree.suitableForMode(TravelMode.paAssisted), isTrue); + + // Staff assistance is only suitable for PA, not Solo + expect(placeWithStaffAssistance.suitableForMode(TravelMode.solo), isFalse); + expect(placeWithStaffAssistance.suitableForMode(TravelMode.paAssisted), isTrue); + }); + }); + group('AccessibilityVisibilityPolicy', () { const policy = AccessibilityVisibilityPolicy(); test('wheelchair score hidden for visual-only profile', () { - final profile = UserProfile.mockUser(); // cannotSee + final profile = UserProfile.empty().copyWith(accessibilityNeeds: [AccessibilityNeed.blindLowVision]); expect(policy.showWheelchairScore(profile), isFalse); }); - test('wheelchair score visible for wheelchairUser', () { - final profile = UserProfile.mockUser() - .copyWith(accessibilityNeeds: [AccessibilityNeed.wheelchairUser]); - expect(policy.showWheelchairScore(profile), isTrue); + test('shouldShowWheelchairScore returns true when physical needs exist', () { + final user = UserProfile.empty().copyWith( + accessibilityNeeds: [AccessibilityNeed.wheelchairMobility], + ); + expect(user.shouldShowWheelchairScore, isTrue); }); - test('wheelchair score visible for physicallyDisabled', () { - final profile = UserProfile.mockUser().copyWith( - accessibilityNeeds: [AccessibilityNeed.physicallyDisabled], + test('shouldShowWheelchairScore returns false when no physical needs exist', () { + final user = UserProfile.empty().copyWith( + accessibilityNeeds: [AccessibilityNeed.blindLowVision], ); - expect(policy.showWheelchairScore(profile), isTrue); + expect(user.shouldShowWheelchairScore, isFalse); }); test('visual score promoted for cannotSee profile', () { - final profile = UserProfile.mockUser(); + final profile = UserProfile.empty().copyWith(accessibilityNeeds: [AccessibilityNeed.blindLowVision]); expect(policy.showVisualScore(profile), isTrue); expect(policy.showHearingScore(profile), isFalse); expect(policy.showCommunicationScore(profile), isFalse); }); test('communication score promoted for cannotSpeak profile', () { - final profile = UserProfile.mockUser() - .copyWith(accessibilityNeeds: [AccessibilityNeed.cannotSpeak]); + final profile = UserProfile.empty() + .copyWith(accessibilityNeeds: [AccessibilityNeed.speechCommunication]); expect(policy.showCommunicationScore(profile), isTrue); }); + + test('copyWith updates fields correctly', () { + final user = UserProfile.empty(); + final updated = user.copyWith( + displayName: 'New Name', + accessibilityNeeds: [AccessibilityNeed.speechCommunication], + ); + expect(updated.displayName, 'New Name'); + }); }); group('MockPlaceRepository', () { diff --git a/test/widget_test.dart b/test/widget_test.dart index 3f8511e..a3712bb 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -3,45 +3,41 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - testWidgets('onboarding requires accessibility need before map opens', ( + testWidgets('welcome onboarding displays brand and capabilities', ( tester, ) async { - await tester.pumpWidget(const AccessMapApp()); - await tester.pumpAndSettle(); + tester.view.physicalSize = const Size(800, 1200); + addTearDown(tester.view.resetPhysicalSize); - expect(find.text('How are you using the app?'), findsOneWidget); - await tester.tap(find.text('PA Assisted')); - await tester.pump(); - await tester.tap(find.text('Continue')); + await tester.pumpWidget(const AccessMapApp()); await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 500)); - expect( - find.text('What accessibility needs should we consider?'), - findsOneWidget, - ); - final openMap = tester.widget( - find.ancestor( - of: find.text('Open Map'), - matching: find.bySubtype(), - ), - ); - expect(openMap.onPressed, isNull); + expect(find.text('AccessSetu'), findsOneWidget); + expect(find.text('GET STARTED & EXPLORE MAP'), findsOneWidget); + expect(find.text('Blind / Low Vision'), findsOneWidget); }); testWidgets('user can complete onboarding and see map shell', (tester) async { + tester.view.physicalSize = const Size(800, 1200); + addTearDown(tester.view.resetPhysicalSize); + await tester.pumpWidget(const AccessMapApp()); await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 500)); - await tester.tap(find.text('PA Assisted')); - await tester.pump(); - await tester.tap(find.text('Continue')); + final needFinder = find.text('Blind / Low Vision'); + await tester.ensureVisible(needFinder); await tester.pumpAndSettle(); - await tester.tap(find.text("Can't See")); - await tester.pump(); - await tester.ensureVisible(find.text('Open Map')); + await tester.tap(needFinder); await tester.pump(); - await tester.tap(find.text('Open Map')); - await tester.pump(const Duration(seconds: 1)); + + final ctaFinder = find.text('GET STARTED & EXPLORE MAP'); + await tester.ensureVisible(ctaFinder); + await tester.pumpAndSettle(); + await tester.tap(ctaFinder); + await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 500)); expect(find.text('Map'), findsWidgets); expect(find.text('Search accessible places...'), findsOneWidget); From b976673e8e7b7403851617f4287e28be5346bc0f Mon Sep 17 00:00:00 2001 From: Salim Date: Sat, 12 Sep 2026 20:07:05 +0530 Subject: [PATCH 2/5] chore: remove commands --- README.md | 141 +++++++----------- .../map/presentation/exploration_overlay.dart | 8 +- .../welcome_onboarding_screen.dart | 4 +- 3 files changed, 56 insertions(+), 97 deletions(-) diff --git a/README.md b/README.md index f6b06d5..3f7f171 100644 --- a/README.md +++ b/README.md @@ -2,60 +2,60 @@ > **Bridging Accessible Mobility, Spoken Guidance, and Community Confidence** -AccessSetu is an accessibility-first Flutter application designed to empower individuals with diverse physical, sensory, and communication needs. Engineered around real-world accessibility infrastructure in Goa, India, AccessSetu combines high-precision spatial mapping, spoken landmark navigation, offline resilience, digital disability credentials, and smart emergency tools into a unified, privacy-focused experience. +AccessSetu is an accessibility-first Flutter application designed to empower individuals with physical, sensory, and communication needs. Engineered around real-world accessibility infrastructure in Goa, India, AccessSetu combines spatial mapping, spoken landmark navigation, offline resilience, digital disability credentials, and emergency tools into a unified, privacy-focused mobile experience. --- -## Core Capabilities & Features +## Core Features ### 1. Accessible Map & Spatial Discovery -- **Interactive Geospatial Map**: High-performance OpenStreetMap rendering powered by `flutter_map` and `latlong2`. -- **Multi-Style Map Layers**: Toggle between **Standard OSM**, **Satellite View**, and **Public Transport** layers on demand. -- **3D Perspective Tilt Mode**: Toggle 3D rotation and viewing angles for enhanced streetscape depth perception. -- **Live GPS Tracking ("My Location")**: Instant center-and-follow positioning with real-time accuracy indicators and graceful weak-signal fallbacks. -- **Dynamic Feature Filtering**: Filter places by category (Hospitals, Restaurants, Transit, Pharmacies, etc.), high friendliness threshold (8.5+), and travel mode suitability (Solo vs. Assistant-supported). - -### 2. Lazarillo 360° Spoken Audio Guidance -- **Strict Blind-Only Voice Policy**: Text-to-speech landmark guidance, auditory turn prompts, and direction cues are strictly gated to users with the **Blind / Low Vision** persona to preserve sensory comfort for others. -- **"Where Am I?" Spatial Orientation**: One-tap spoken announcements providing current heading, road location, and nearest verified accessible landmark. -- **Lazarillo Landmark Scanning**: Categorized audio radar for rapid discovery of nearby transit, medical facilities, banks, and dining venues. -- **Proximity Hazard Alerts**: Spoken warnings when approaching reported hazards (e.g., broken ramps, construction barriers, missing tactile paving). +- **Interactive Geospatial Map**: OpenStreetMap rendering powered by `flutter_map` and `latlong2`. +- **Multi-Style Map Layers**: Toggle between Standard OSM, Satellite View, and Public Transport layers. +- **3D Perspective Tilt Mode**: Perspective tilt and rotation for depth perception and street orientation. +- **Live GPS Tracking**: One-tap center-and-follow positioning with real-time accuracy and weak-signal fallbacks. +- **Accessibility Filtering**: Filter places by category (Hospitals, Restaurants, Transit, Pharmacies), high friendliness threshold (8.5+), and travel mode suitability. + +### 2. Spoken Audio Navigation & Landmark Guidance +- **Strict Blind-Only Voice Policy**: Spoken cues, auditory turn prompts, and landmark announcements are strictly gated to the **Blind / Low Vision** persona. +- **"Where Am I?" Spatial Orientation**: Spoken announcement providing heading, street location, and nearest verified landmark. +- **Audio Landmark Scanning**: Rapid category exploration for nearby transit, medical facilities, banks, and dining venues. +- **Proximity Hazard Alerts**: Audio warnings when approaching reported obstacles (broken ramps, construction, missing tactile paving). ### 3. Digital Disability Pass & UDID Medical ID -- **Offline Assistance ID Card**: Clean, high-contrast digital card modeled after official disability credentials for transit conductors, station staff, and officials. -- **UDID Verification & Details**: Displays UDID number, disability category, disability percentage, issuing authority, and validity dates. -- **Smart Document Scanner (OCR)**: On-device optical character recognition via Google ML Kit to scan physical UDID certificates and extract certificate numbers automatically. -- **ICE Medical Card & Emergency QR**: Instant access to blood group, chronic conditions, allergy notes, and quick-scan emergency medical QR code. +- **Offline Assistance ID Card**: High-contrast digital ID modeled after official credentials for transit conductors and staff. +- **UDID Verification & Details**: Displays UDID number, disability category, percentage, issuing authority, and dates. +- **Smart Document Scanner (OCR)**: On-device optical character recognition via Google ML Kit to extract UDID certificate numbers from physical documents. +- **ICE Medical Card & Emergency QR**: Instant access to blood group, conditions, allergy notes, and emergency medical QR code. ### 4. Smart SOS Safety Beacon -- **Accidental-Tap Protection**: 3-second interactive countdown with vibration feedback to cancel false alarms. -- **Audible Siren Beacon**: High-volume repeating siren loop for personal safety and attracting immediate bystander assistance. -- **Voice Help Broadcast**: Synthesized emergency speech loop announcing identity, condition, and distress message. -- **1-Tap ICE Dialer**: Pre-configured emergency contacts (family, doctor, helpline) with direct phone launch and SMS coordinate dispatch. +- **Accidental-Tap Protection**: 3-second interactive countdown with cancel button and vibration feedback. +- **Audible Siren Beacon**: High-volume repeating siren loop for immediate personal safety and bystander alerting. +- **Voice Help Broadcast**: Synthesized emergency speech loop announcing identity and distress message. +- **1-Tap ICE Dialer**: Pre-configured emergency contacts (family, doctor, helpline) with direct phone and SMS dispatch. ### 5. Offline Map Package Management -- **Localized Regional Packs**: Download focused offline tile packages rather than bulky global data: +- **Regional Offline Packs**: Download focused offline tile packages: - **Panaji City Center & Mandovi** (~45 MB) - **North Goa Coastal Belt** (~85 MB) - **South Goa Heritage & Margao** (~65 MB) -- **Storage & State Controls**: Progress-tracked downloads, local storage accounting, and one-tap deletion. +- **Storage Management**: Progress-tracked downloads, local storage accounting, and one-tap deletion. ### 6. Community Verification & Reviews -- **Feature Confirmation (+3 Points)**: Community members confirm or dispute individual accessibility amenities (step-free ramps, wide doorways, braille signage, elevators) with timestamped verification. -- **Structured 1-10 Reviews (+5 Points)**: Multi-dimensional ratings evaluating staff awareness, communication patience, assistance readiness, physical accessibility, and restroom facilities. -- **Community Points & History**: Earn points toward local access advocate badges, complete with an activity feed in the Profile & Community tabs. +- **Feature Confirmation (+3 Points)**: Verify individual accessibility features (step-free ramps, wide doors, braille, elevators). +- **Structured 1-10 Reviews (+5 Points)**: Multi-dimensional ratings across staff, communication, assistance, physical access, and restrooms. +- **Community Points & History**: Earn points toward local access advocate badges with activity tracking. --- -## Technology Stack +## Tech Stack -- **Framework**: Flutter 3.x / Dart 3.x (Null-safe) -- **State Management**: `provider` (`AppState` architecture) +- **Framework**: Flutter 3.x / Dart 3.x +- **State Management**: `provider` (`AppState`) - **Map & Geolocation**: `flutter_map`, `latlong2`, `geolocator` - **Speech & Audio**: `flutter_tts`, `speech_to_text`, `audioplayers` - **Machine Learning**: `google_mlkit_text_recognition` -- **Hardware Integration**: `image_picker`, `url_launcher`, `qr_flutter` -- **Testing**: `flutter_test` (Unit, Widget, and End-to-End Acceptance Suites) +- **Hardware & Utilities**: `image_picker`, `url_launcher`, `qr_flutter` +- **Testing**: `flutter_test` --- @@ -64,96 +64,55 @@ AccessSetu is an accessibility-first Flutter application designed to empower ind ```text lib/ ├── app/ -│ ├── access_map_app.dart # Root MaterialApp with theme & navigation -│ └── app_state.dart # Unified central business logic & services coordinator +│ ├── access_map_app.dart # Root MaterialApp +│ └── app_state.dart # Main state coordinator ├── core/ -│ ├── services/ # Service layer (TTS, Exploration, Offline Maps, OCR, Voice) -│ ├── theme/ # Design tokens, typography, radii, and color palettes -│ └── utils/ # Visibility policies and mathematical helpers +│ ├── services/ # TTS, exploration, offline maps, OCR, voice services +│ ├── theme/ # App theme, typography, colors +│ └── utils/ # Visibility policies and math helpers ├── features/ -│ ├── contributions/ # Community points, activity logs, and review summaries -│ ├── emergency/ # Smart SOS beacon, audible alarm, and ICE contacts -│ ├── map/ # MapScreen, 3D tilt, Discover tab, Lazarillo HUD overlay -│ ├── onboarding/ # Accessible Welcome onboarding & persona configuration -│ ├── places/ # Place details, friendly score sheets, and directions -│ ├── profile/ # Profile management, Disability Pass, and Offline Maps -│ └── reviews/ # Structured 1-10 review submission flow +│ ├── contributions/ # Points, activity history, and review summaries +│ ├── emergency/ # SOS beacon, siren, and ICE contacts +│ ├── map/ # MapScreen, 3D tilt, Discover tab, navigation HUD +│ ├── onboarding/ # Welcome onboarding & persona selection +│ ├── places/ # Place details, friendly score sheets, directions +│ ├── profile/ # Profile settings, Disability Pass, Offline Maps +│ └── reviews/ # Structured 1-10 review submission └── shared/ - ├── models/ # Place, UserProfile, Hazard, MapStyle, Category, Feature models - └── widgets/ # Accessible buttons, search bar, cards, and bottom sheets + ├── models/ # Place, UserProfile, Hazard, MapStyle models + └── widgets/ # Buttons, search bar, cards, bottom sheets ``` --- -## Getting Started +## Quickstart -### Prerequisites -- [Flutter SDK](https://docs.flutter.dev/get-started/install) (`^3.6.0` or later) -- Android Studio / VS Code with Flutter extensions -- Android device or emulator with API Level 24+ - -### Installation & Run +### Run Locally ```bash -# Clone the repository -git clone https://github.com/virtuallysarvad/access_app.git -cd access_app - -# Fetch dependencies flutter pub get - -# Launch the app on an active device or emulator flutter run ``` ---- - -## Automated Test Suite - -All tests are verified and passing: +### Run Tests ```bash -# Run all 29 tests across unit, geo, widget, and acceptance suites flutter test - -# Run static analysis flutter analyze lib test ``` -### Test Coverage Highlights: -- **`test/models_test.dart` (10 tests)**: Accessibility visibility policies, travel mode suitability, and score calculations. -- **`test/geo_test.dart` (8 tests)**: Great-circle Haversine distances, compass bearings, and navigation deviation math. -- **`test/widget_test.dart` (2 tests)**: Onboarding flow and main shell navigation. -- **`test/acceptance_flow_test.dart` (9 tests)**: End-to-end user journeys including onboarding, map search, Discover tab, review submission with points accumulation, helpful votes, and feature confirmations. - --- -## Merging with `origin/main` +## Merging with `main` -If you are syncing this feature branch with teammate commits from `origin/main` (e.g. Community Location Submission Wizard and Adaptive Launcher Icons): +To sync and merge this branch into `main`: ```bash -# 1. Ensure you are on this branch git checkout access-setu - -# 2. Fetch and merge upstream changes git fetch origin git merge origin/main - -# 3. Resolve any conflicts in lib/app/app_state.dart and lib/shared/models/place.dart -# (Keep both Lazarillo/Offline services and their community location submission methods) - -# 4. Verify the test suite flutter test - -# 5. Merge back to main and push git checkout main git merge access-setu git push origin main ``` - ---- - -## License - -This project is developed for the Google Developer Groups (GDG) hackathon. All rights reserved. diff --git a/lib/features/map/presentation/exploration_overlay.dart b/lib/features/map/presentation/exploration_overlay.dart index c5a9952..46dcc61 100644 --- a/lib/features/map/presentation/exploration_overlay.dart +++ b/lib/features/map/presentation/exploration_overlay.dart @@ -28,7 +28,7 @@ class ExplorationOverlay extends StatelessWidget { child: SafeArea( child: isNavigating ? _buildGoogleMapsNavigationHUD(context, exploration, state) - : _buildLazarilloCategoryExplorer(context, exploration, state), + : _buildCategoryExplorer(context, exploration, state), ), ), ); @@ -37,9 +37,9 @@ class ExplorationOverlay extends StatelessWidget { } // ========================================== - // 1. LAZARILLO-STYLE CATEGORY EXPLORER VIEW + // 1. CATEGORY AUDIO EXPLORER VIEW // ========================================== - Widget _buildLazarilloCategoryExplorer( + Widget _buildCategoryExplorer( BuildContext context, ExplorationService exploration, AppState state, @@ -237,7 +237,7 @@ class ExplorationOverlay extends StatelessWidget { ), const SizedBox(height: AppSpacing.sm), - // Lazarillo Grid: 6 Big Accessible Category Cards + // Category Grid: 6 Big Accessible Category Cards Expanded( child: GridView.builder( gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( diff --git a/lib/features/onboarding/presentation/welcome_onboarding_screen.dart b/lib/features/onboarding/presentation/welcome_onboarding_screen.dart index bfe34c3..0775fb1 100644 --- a/lib/features/onboarding/presentation/welcome_onboarding_screen.dart +++ b/lib/features/onboarding/presentation/welcome_onboarding_screen.dart @@ -127,8 +127,8 @@ class _WelcomeOnboardingScreenState extends State { _FeatureRow( icon: Icons.explore, iconColor: Colors.blue, - title: 'Lazarillo Spoken Guidance', - subtitle: '360° audio explorer and spoken landmarks ("Where Am I?") for blind navigation.', + title: 'Spoken Audio Guidance', + subtitle: 'Audio landmark announcements ("Where Am I?") and turn cues for blind navigation.', ), Divider(height: 20), _FeatureRow( From ff6f4f699ff0cda11ed93cb252f1748f8988083a Mon Sep 17 00:00:00 2001 From: Salim Date: Sat, 12 Sep 2026 22:01:27 +0530 Subject: [PATCH 3/5] docs: update README with OSRM navigation and add CI/CD release workflow --- .github/workflows/release.yml | 111 +++++----------------------------- README.md | 43 +++++++------ 2 files changed, 38 insertions(+), 116 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 351103b..f992c24 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,35 +1,27 @@ -name: Build & Release AccessSetu APK +name: Release AccessSetu on: push: tags: - 'v*' branches: - - main - - master - access-setu + - main workflow_dispatch: - inputs: - release_title: - description: 'Release Title (e.g. AccessSetu v1.0.0)' - required: false - default: 'AccessSetu Official Release' permissions: contents: write jobs: - build-and-release: - name: Build Release APK & Bundle + release: + name: Build & Release APK runs-on: ubuntu-latest steps: - - name: Checkout Code + - name: Checkout uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Set up Java 17 (JDK) + - name: Set up Java 17 uses: actions/setup-java@v4 with: distribution: 'temurin' @@ -42,91 +34,16 @@ jobs: channel: 'stable' cache: true - - name: Install Dependencies - run: flutter pub get - - - name: Analyze Project - run: flutter analyze --no-fatal-infos - - - name: Extract App Version - id: get_version + - name: Build Release APK run: | - RAW_VERSION=$(grep '^version:' pubspec.yaml | sed 's/version: //' | tr -d '\r' | tr -d ' ') - VERSION_NAME=$(echo $RAW_VERSION | cut -d'+' -f1) - BUILD_NUMBER=$(echo $RAW_VERSION | cut -d'+' -f2) - if [ -z "$BUILD_NUMBER" ] || [ "$BUILD_NUMBER" = "$VERSION_NAME" ]; then - BUILD_NUMBER="${{ github.run_number }}" - fi - TAG_NAME="${{ github.ref_name }}" - if [[ ! "$TAG_NAME" =~ ^v ]]; then - TAG_NAME="v${VERSION_NAME}" - fi - echo "version_name=$VERSION_NAME" >> $GITHUB_OUTPUT - echo "build_number=$BUILD_NUMBER" >> $GITHUB_OUTPUT - echo "tag_name=$TAG_NAME" >> $GITHUB_OUTPUT - echo "Building AccessSetu version: $VERSION_NAME+$BUILD_NUMBER (Tag: $TAG_NAME)" - - - name: Build Universal Release APK - run: | - flutter build apk --release \ - --build-name="${{ steps.get_version.outputs.version_name }}" \ - --build-number="${{ steps.get_version.outputs.build_number }}" - - - name: Build Split-per-ABI Release APKs - run: | - flutter build apk --release --split-per-abi \ - --build-name="${{ steps.get_version.outputs.version_name }}" \ - --build-number="${{ steps.get_version.outputs.build_number }}" - - - name: Build Release App Bundle (AAB) - run: | - flutter build appbundle --release \ - --build-name="${{ steps.get_version.outputs.version_name }}" \ - --build-number="${{ steps.get_version.outputs.build_number }}" - - - name: Prepare & Rename Release Artifacts - run: | - mkdir -p dist - V="${{ steps.get_version.outputs.version_name }}" - - # Universal APK (main release) - if [ -f "build/app/outputs/flutter-apk/app-release.apk" ]; then - cp build/app/outputs/flutter-apk/app-release.apk "dist/AccessSetu-v${V}-universal-release.apk" - fi - - # Architecture-specific APKs (faster install & smaller footprint) - if [ -f "build/app/outputs/flutter-apk/app-arm64-v8a-release.apk" ]; then - cp build/app/outputs/flutter-apk/app-arm64-v8a-release.apk "dist/AccessSetu-v${V}-arm64-v8a-release.apk" - fi - if [ -f "build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk" ]; then - cp build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk "dist/AccessSetu-v${V}-armeabi-v7a-release.apk" - fi - if [ -f "build/app/outputs/flutter-apk/app-x86_64-release.apk" ]; then - cp build/app/outputs/flutter-apk/app-x86_64-release.apk "dist/AccessSetu-v${V}-x86_64-release.apk" - fi - - # Android App Bundle (Google Play Store) - if [ -f "build/app/outputs/bundle/release/app-release.aab" ]; then - cp build/app/outputs/bundle/release/app-release.aab "dist/AccessSetu-v${V}-release.aab" - fi - - echo "=== Ready Artifacts for Release ===" - ls -lh dist/ - - - name: Upload Artifacts to Workflow Run - uses: actions/upload-artifact@v4 - with: - name: AccessSetu-v${{ steps.get_version.outputs.version_name }}-Release-Binaries - path: dist/* - if-no-files-found: error + flutter pub get + flutter build apk --release + cp build/app/outputs/flutter-apk/app-release.apk AccessSetu-release.apk - - name: Publish GitHub Release + - name: GitHub Release uses: softprops/action-gh-release@v2 - if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' with: - tag_name: ${{ steps.get_version.outputs.tag_name }} - name: ${{ github.event.inputs.release_title || format('AccessSetu {0}', steps.get_version.outputs.tag_name) }} - files: dist/* + tag_name: ${{ github.ref_name }} + name: AccessSetu ${{ github.ref_name }} + files: AccessSetu-release.apk generate_release_notes: true - draft: false - prerelease: false diff --git a/README.md b/README.md index 89e7f3a..5528f9b 100644 --- a/README.md +++ b/README.md @@ -15,10 +15,15 @@ AccessSetu is an accessibility-first Flutter application designed to empower ind - **Live GPS Tracking**: One-tap center-and-follow positioning with real-time accuracy and weak-signal fallbacks. - **Accessibility Filtering**: Filter places by category (Hospitals, Restaurants, Transit, Pharmacies), high friendliness threshold (8.5+), and travel mode suitability. -### 2. Spoken Audio Navigation & Landmark Guidance -- **Strict Blind-Only Voice Policy**: Spoken cues, auditory turn prompts, and landmark announcements are strictly gated to the **Blind / Low Vision** persona. +### 2. Multi-Modal Navigation & Spoken Turn Guidance (OSRM) +- **Open-Source Pedestrian & Vehicle Routing**: Direct integration with OSRM (Open Source Routing Machine) for real street navigation: + - 🚶 **Walk**: Sidewalks, pedestrian crossings, footpaths, and step-free routes. + - 🚴 **Bike**: Cycling corridors and bike-friendly roads. + - 🚗 **Car**: Vehicular driving routes. +- **15-Second Periodic Voice Guidance**: When navigating, if stationary or paused, spoken prompts announce exact turn degrees and remaining distance (e.g., *"Turn 19 degrees right, then walk 45 meters"*). +- **Gyroscope & Compass Dial**: Real-time compass heading with live relative angle badge (`R19°`, `L25°`, `0°`) pointing directly toward the next maneuver. +- **Dynamic Travel Time & Speed**: ETA adjusts in real-time based on live GPS walking/driving speed. - **"Where Am I?" Spatial Orientation**: Spoken announcement providing heading, street location, and nearest verified landmark. -- **Audio Landmark Scanning**: Rapid category exploration for nearby transit, medical facilities, banks, and dining venues. - **Proximity Hazard Alerts**: Audio warnings when approaching reported obstacles (broken ramps, construction, missing tactile paving). ### 3. Digital Disability Pass & UDID Medical ID @@ -54,22 +59,26 @@ AccessSetu is an accessibility-first Flutter application designed to empower ind - **Framework**: Flutter 3.x / Dart 3.x - **State Management**: `provider` (`AppState`) - **Map & Geolocation**: `flutter_map`, `latlong2`, `geolocator`, `geocoding` (`GeocodingService`) +- **Routing Engine**: OSRM API (Walk `/foot`, Bike `/bike`, Car `/driving`) - **Speech & Audio**: `flutter_tts`, `speech_to_text`, `audioplayers` - **Machine Learning**: `google_mlkit_text_recognition` -- **Hardware & Utilities**: `image_picker`, `url_launcher`, `qr_flutter`, `shared_preferences` -- **Testing**: `flutter_test` +- **Hardware & Sensors**: `flutter_compass`, `vibration`, `image_picker`, `qr_flutter` +- **CI/CD**: GitHub Actions automated release pipeline --- ## Project Structure ```text +.github/ +└── workflows/ + └── release.yml # Automated APK release pipeline lib/ ├── app/ │ ├── access_map_app.dart # Root MaterialApp │ └── app_state.dart # Main state coordinator ├── core/ -│ ├── services/ # TTS, exploration, offline maps, OCR, voice services +│ ├── services/ # OSRM routing, TTS, exploration, offline maps, OCR │ ├── theme/ # App theme, typography, colors │ └── utils/ # Visibility policies and math helpers ├── features/ @@ -96,25 +105,21 @@ flutter pub get flutter run ``` -### Run Tests +### Build Release APK ```bash -flutter test -flutter analyze lib test +flutter build apk --release ``` ---- +Output APK will be at `build/app/outputs/flutter-apk/app-release.apk`. -## Merging with `main` +### Automated GitHub Release (CI/CD) -To sync and merge this branch into `main`: +Tag any commit with `v*` to automatically trigger the GitHub Actions release workflow: ```bash -git checkout access-setu -git fetch origin -git merge origin/main -flutter test -git checkout main -git merge access-setu -git push origin main +git tag v1.0.0 +git push origin access-setu --tags ``` + +The workflow will compile a production release APK and publish it directly to your repository's **Releases** page as `AccessSetu-release.apk`. From b871062747a2220735ba05ec445205a76f25364e Mon Sep 17 00:00:00 2001 From: Salim Date: Sat, 12 Sep 2026 22:05:21 +0530 Subject: [PATCH 4/5] chore: update .gitignore and include gradle-wrapper.jar for CI --- .gitignore | 12 ++++++++---- android/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 53636 bytes 2 files changed, 8 insertions(+), 4 deletions(-) create mode 100644 android/gradle/wrapper/gradle-wrapper.jar diff --git a/.gitignore b/.gitignore index 6792ddd..f39afbc 100644 --- a/.gitignore +++ b/.gitignore @@ -5,12 +5,9 @@ *.swp .DS_Store .atom/ -.build/ .buildlog/ .history .svn/ -.swiftpm/ -migrate_working_dir/ # IntelliJ related *.iml @@ -30,6 +27,7 @@ migrate_working_dir/ .pub-cache/ .pub/ /build/ +dist/ /coverage/ # Symbolication & Obfuscation @@ -37,10 +35,12 @@ app.*.symbols app.*.map.json # Android -**/android/**/gradle-wrapper.jar .gradle/ /android/.gradle/ /android/local.properties +/android/key.properties +*.keystore +*.jks /android/app/debug /android/app/profile /android/app/release @@ -48,6 +48,10 @@ app.*.map.json *.apk *.aab +# Ensure Gradle wrapper is tracked for CI/CD builds +!**/android/**/gradle/wrapper/gradle-wrapper.jar +!**/gradle/wrapper/gradle-wrapper.jar + # iOS **/ios/**/Pods/ **/ios/**/.symlinks/ diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..13372aef5e24af05341d49695ee84e5f9b594659 GIT binary patch literal 53636 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ⅅ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 H-+%o-(+5aQ literal 0 HcmV?d00001 From 0bfcf021c96b599dc58d7a72a25ab261a4f9ca56 Mon Sep 17 00:00:00 2001 From: Salim Date: Sat, 12 Sep 2026 22:08:36 +0530 Subject: [PATCH 5/5] ci: trigger release on access-setu branch with AccessSetu-test.apk --- .github/workflows/release.yml | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f992c24..6f1a259 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,11 +2,11 @@ name: Release AccessSetu on: push: - tags: - - 'v*' branches: - access-setu - - main + tags: + - 'v*' + - 'test*' workflow_dispatch: permissions: @@ -38,12 +38,24 @@ jobs: run: | flutter pub get flutter build apk --release + cp build/app/outputs/flutter-apk/app-release.apk AccessSetu-test.apk cp build/app/outputs/flutter-apk/app-release.apk AccessSetu-release.apk + - name: Determine Tag Name + id: tag + run: | + if [[ "${{ github.ref }}" == refs/tags/* ]]; then + echo "name=${{ github.ref_name }}" >> $GITHUB_OUTPUT + else + echo "name=test" >> $GITHUB_OUTPUT + fi + - name: GitHub Release uses: softprops/action-gh-release@v2 with: - tag_name: ${{ github.ref_name }} - name: AccessSetu ${{ github.ref_name }} - files: AccessSetu-release.apk + tag_name: ${{ steps.tag.outputs.name }} + name: AccessSetu Test Release (${{ steps.tag.outputs.name }}) + files: | + AccessSetu-test.apk + AccessSetu-release.apk generate_release_notes: true