diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..6f1a259 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,61 @@ +name: Release AccessSetu + +on: + push: + branches: + - access-setu + tags: + - 'v*' + - 'test*' + workflow_dispatch: + +permissions: + contents: write + +jobs: + release: + name: Build & Release APK + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Java 17 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + cache: 'gradle' + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + channel: 'stable' + cache: true + + - name: Build Release APK + 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: ${{ steps.tag.outputs.name }} + name: AccessSetu Test Release (${{ steps.tag.outputs.name }}) + files: | + AccessSetu-test.apk + AccessSetu-release.apk + generate_release_notes: true diff --git a/.gitignore b/.gitignore index 3820a95..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 @@ -18,28 +15,51 @@ 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/ +dist/ /coverage/ -# Symbolication related +# Symbolication & Obfuscation app.*.symbols - -# Obfuscation related app.*.map.json -# Android Studio will place build artifacts here +# Android +.gradle/ +/android/.gradle/ +/android/local.properties +/android/key.properties +*.keystore +*.jks /android/app/debug /android/app/profile /android/app/release +/android/build/ +*.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/ +**/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 7e4e7c9..5528f9b 100644 --- a/README.md +++ b/README.md @@ -1,97 +1,125 @@ -# AccessMap - -AccessMap is an accessibility-first community map for discovering places that are welcoming and usable for people with different accessibility needs. - -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. - -## 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 -- Community location submission: 5-step Add Location flow (pick on map or search, basic info, tri-state accessibility survey, photos, review) with duplicate detection, +10 points, provisional "New" scores until the community rates the place, and offline persistence via SharedPreferences -- Report incorrect place information -- Discover screen with category chips, Popular Near You, Highly Rated by Community, and Recently Added -- Profile editor with a demo wheelchair profile toggle for showing personalization behavior +# AccessSetu (एक्सेस सेतु) + +> **Bridging Accessible Mobility, Spoken Guidance, and Community Confidence** + +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 Features + +### 1. Accessible Map & Spatial Discovery +- **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. 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. +- **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**: 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 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 +- **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 Management**: Progress-tracked downloads, local storage accounting, and one-tap deletion. + +### 6. Community Place Submission & Reviews +- **5-Step Location Submission Wizard**: Add missing places with interactive map pin picker, reverse geocoding, duplicate detection, tri-state accessibility survey, photo placeholders, and +10 Community Points. +- **Local Persistence**: Community submissions persist offline via SharedPreferences alongside seed demo places. +- **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. + +--- ## Tech Stack -- 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, with a SharedPreferences-backed store for community-contributed places -- `geolocator` + local fallback geocoding behind a `GeocodingService` for location search and reverse geocoding +- **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 & Sensors**: `flutter_compass`, `vibration`, `image_picker`, `qr_flutter` +- **CI/CD**: GitHub Actions automated release pipeline -## Running the Project +--- -```bash -flutter pub get -flutter run +## 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/ # OSRM routing, TTS, exploration, offline maps, OCR +│ ├── theme/ # App theme, typography, colors +│ └── utils/ # Visibility policies and math helpers +├── features/ +│ ├── 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 models + └── widgets/ # Buttons, search bar, cards, bottom sheets ``` -Run tests with: +--- -```bash -flutter test # 40 tests: unit + acceptance + add-location flow -flutter analyze # static analysis -``` +## Quickstart -Build and install the Android APK: +### Run Locally ```bash -flutter build apk --debug # or: cd android && ./gradlew assembleDebug -adb install -r build/app/outputs/flutter-apk/app-debug.apk +flutter pub get +flutter run ``` -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. +### Build Release APK -## Environment Variables and API Keys - -No API key is required for the MVP demo mode. - -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. - -## Architecture - -```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 +```bash +flutter build apk --release ``` -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. +Output APK will be at `build/app/outputs/flutter-apk/app-release.apk`. -## Known Limitations +### Automated GitHub Release (CI/CD) -- 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. +Tag any commit with `v*` to automatically trigger the GitHub Actions release workflow: -## Acceptance Checklist +```bash +git tag v1.0.0 +git push origin access-setu --tags +``` -- 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 +The workflow will compile a production release APK and publish it directly to your repository's **Releases** page as `AccessSetu-release.apk`. 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 cdcbe83..ac192ca 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -3,7 +3,7 @@ import java.io.FileInputStream 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") } @@ -30,7 +30,7 @@ android { defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "com.accessmap.access_map" + applicationId = "com.accessmap.combined" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = flutter.minSdkVersion diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 0b93053..c545f6e 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,11 +1,12 @@ - + + ( 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 b100c26..109e88b 100644 --- a/lib/app/app_state.dart +++ b/lib/app/app_state.dart @@ -1,10 +1,15 @@ import 'package:access_map/core/services/geocoding_service.dart'; 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'; @@ -16,23 +21,49 @@ class AppState extends ChangeNotifier { VoiceSearchService? voiceSearchService, NavigationService? navigationService, GeocodingService? geocodingService, + ProfileStorageService? profileStorageService, + TTSService? ttsService, + ExplorationService? explorationService, + OfflineMapService? offlineMapService, }) : _placeRepository = placeRepository ?? MockPlaceRepository(), _voiceSearchService = voiceSearchService ?? SpeechToTextService(), _navigationService = navigationService ?? NavigationService(), - _geocodingService = geocodingService ?? const GeocodingService(); + _geocodingService = geocodingService ?? const GeocodingService(), + _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 GeocodingService _geocodingService; + final ProfileStorageService _profileStorageService; + final TTSService _ttsService; + late final ExplorationService _explorationService; + final OfflineMapService _offlineMapService; GeocodingService get geocodingService => _geocodingService; PlaceRepository get placeRepository => _placeRepository; - UserProfile _profile = UserProfile.mockUser().copyWith( - onboardingComplete: false, - accessibilityNeeds: const [], - ); + UserProfile _profile = UserProfile.empty(); List _places = []; List _visiblePlaces = []; Place? _selectedPlace; @@ -56,6 +87,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; @@ -64,7 +115,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 { @@ -75,8 +126,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) { @@ -86,25 +136,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 { @@ -127,6 +180,11 @@ class AppState extends ChangeNotifier { notifyListeners(); } + void clearSelectedPlace() { + _selectedPlace = null; + notifyListeners(); + } + void changeTab(int index) { _tabIndex = index; notifyListeners(); @@ -146,6 +204,48 @@ 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); + } + + Future toggleExplorationMode() async { + if (_explorationService.isActive) { + _explorationService.stopExploration(); + notifyListeners(); + return null; + } else { + final error = await _explorationService.startExploration(); + notifyListeners(); + return error; + } + } + + + Future openDirections(Place place) { return _navigationService.openExternalDirections(place); } @@ -190,12 +290,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(); } @@ -215,11 +315,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(); } @@ -253,12 +353,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(); } @@ -329,9 +429,9 @@ class AppState extends ChangeNotifier { placeId: place.id, placeName: place.name, ); - _profile = _profile.copyWith( + _updateProfile(_profile.copyWith( recentActivity: [contribution, ..._profile.recentActivity], - ); + )); notifyListeners(); } @@ -355,13 +455,12 @@ class AppState extends ChangeNotifier { placeId: stored.id, placeName: stored.name, ); - _profile = _profile.copyWith( + _updateProfile(_profile.copyWith( communityPoints: _profile.communityPoints + ContributionType.locationAdd.pointsEarned, locationCount: _profile.locationCount + 1, recentActivity: [contribution, ..._profile.recentActivity], - ); - notifyListeners(); + )); return stored; } } 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..d2e0937 --- /dev/null +++ b/lib/core/services/exploration_service.dart @@ -0,0 +1,1079 @@ +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/core/services/osrm_routing_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(); + + /// All hazards including resolved (for showing history on map) + List get allHazards => List.unmodifiable(_hazards); + + HazardReport? _nearbyHazard; + HazardReport? get nearbyHazard => _nearbyHazard; + + final Set _announcedHazardIds = {}; + + ExplorationService(this._ttsService, this._placeRepository); + + List _navigationSteps = []; + List get navigationSteps => _navigationSteps; + + int _currentStepIndex = 0; + int get currentStepIndex => _currentStepIndex; + + double _simulatedWalkDistance = 0; + + NavigationStep? get currentStep { + if (_navigationSteps.isEmpty) return null; + if (_currentStepIndex >= _navigationSteps.length) { + return _navigationSteps.last; + } + return _navigationSteps[_currentStepIndex]; + } + + double get currentStepRemainingMeters { + if (currentStep == null) return 0; + return currentStep!.distanceMeters; + } + + List _routePolyline = []; + + /// Dynamic walking route polyline from user position to active destination + List get routePolylinePoints { + if (_activeDestination == null) return []; + if (_routePolyline.isNotEmpty) return _routePolyline; + final start = LatLng(currentEffectiveLat, currentEffectiveLng); + final end = LatLng(_activeDestination!.latitude, _activeDestination!.longitude); + return [start, end]; + } + + bool _isActive = false; + bool get isActive => _isActive; + + bool _isRouting = false; + bool get isRouting => _isRouting; + + Timer? _periodicGuidanceTimer; + LatLng? _lastGuidancePosition; + + Position? _currentPosition; + Position? get currentPosition => _currentPosition; + + double? _currentHeading; + double? get currentHeading => _currentHeading; + + OsrmProfile _currentRoutingProfile = OsrmProfile.walk; + OsrmProfile get currentRoutingProfile => _currentRoutingProfile; + + void setRoutingProfile(OsrmProfile profile) { + if (_currentRoutingProfile == profile) return; + _currentRoutingProfile = profile; + notifyListeners(); + + if (_activeDestination != null) { + final startLat = currentEffectiveLat; + final startLng = currentEffectiveLng; + final endLat = _activeDestination!.latitude; + final endLng = _activeDestination!.longitude; + final rawDist = Geolocator.distanceBetween(startLat, startLng, endLat, endLng); + + _fetchRealWalkingRoute(startLat, startLng, endLat, endLng, _activeDestination!.name, rawDist); + + if (_ttsService.isEnabled) { + _ttsService.speak('Switched to ${profile.displayName} navigation.'); + } + } + } + + /// Live GPS speed in m/s (from geolocator position stream). Null when unknown. + double? get currentSpeedMs { + final spd = _currentPosition?.speed; + if (spd == null || spd < 0) return null; + return spd; + } + + /// Speed in m/min — uses live GPS speed if available, else default for current transport mode. + double get effectiveSpeedMPerMin { + final spd = currentSpeedMs; + if (spd != null && spd > 0.3) { + // GPS speed is m/s → convert to m/min, clamp to sensible range + return (spd * 60).clamp(20.0, 2000.0); + } + return _currentRoutingProfile.defaultSpeedMPerMin; + } + + /// Compass bearing (0–360°) from current position to the next navigation + /// waypoint (or directly to destination if no steps). + double? get bearingToNextWaypoint { + if (_activeDestination == null) return null; + double destLat, destLng; + if (_navigationSteps.isNotEmpty && + _currentStepIndex < _navigationSteps.length) { + final step = _navigationSteps[_currentStepIndex]; + destLat = step.endLat ?? _activeDestination!.latitude; + destLng = step.endLng ?? _activeDestination!.longitude; + } else { + destLat = _activeDestination!.latitude; + destLng = _activeDestination!.longitude; + } + return Geolocator.bearingBetween( + currentEffectiveLat, + currentEffectiveLng, + destLat, + destLng, + ); + } + + 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; + if (_navigationSteps.isNotEmpty) { + double remaining = 0; + for (int i = _currentStepIndex; i < _navigationSteps.length; i++) { + remaining += _navigationSteps[i].distanceMeters; + } + if (remaining > 0) { + return (remaining - _simulatedWalkDistance).clamp(0.0, remaining); + } + } + final rawDist = Geolocator.distanceBetween( + currentEffectiveLat, + currentEffectiveLng, + _activeDestination!.latitude, + _activeDestination!.longitude, + ); + final remaining = (rawDist - _simulatedWalkDistance).clamp(0.0, rawDist); + return remaining; + } + + /// Estimated walking time in minutes using live GPS speed when available. + int get estimatedWalkingMinutes { + final d = distanceToDestination ?? 0; + if (d <= 0) return 0; + return (d / effectiveSpeedMPerMin).ceil().clamp(1, 480); + } + + /// Formatted direction & turn instruction. + String get navigationInstruction { + if (_activeDestination == null) return ''; + if (currentStep != null) { + return currentStep!.instruction; + } + 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} (${_formatMeters(d)})'; + } + + /// Turn hint for display + String get nextTurnHint { + if (_activeDestination == null) return ''; + if (_navigationSteps.isNotEmpty && _currentStepIndex + 1 < _navigationSteps.length) { + final next = _navigationSteps[_currentStepIndex + 1]; + return 'Then ${next.instruction}'; + } + final d = (distanceToDestination ?? 0).round(); + if (d <= 20) return 'Destination is directly ahead'; + return 'Continue towards step-free accessible entrance'; + } + + static String _formatMeters(int meters) { + if (meters >= 1000) { + return '${(meters / 1000).toStringAsFixed(1)} km'; + } + return '$meters m'; + } + + Future startExploration() async { + if (_isActive) return null; + + bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + await _ttsService.speak('Location services are disabled.'); + return 'Location services are turned off.'; + } + + 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 'Location permission is required for accessible navigation.'; + } + } + + if (permission == LocationPermission.deniedForever) { + await _ttsService.speak('Location permissions are permanently denied.'); + return 'Location permission is permanently denied. Enable it in app settings.'; + } + + _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: 3, + ), + ).listen((Position position) { + _currentPosition = position; + if (position.heading >= 0 && (position.speed) > 0.4) { + _currentHeading = position.heading; + } + _checkDestinationProximity(); + _checkHazardProximity(); + notifyListeners(); + }); + + try { + _compassSub = FlutterCompass.events?.listen((CompassEvent event) { + if (event.heading == null) return; + // On Android accuracy is 0 (unreliable) .. 3 (high). On iOS accuracy < 0 indicates invalid. + if (event.accuracy != null && event.accuracy! <= 0) return; + final newHeading = ((event.heading! % 360) + 360) % 360; + // Only notify if heading changed by more than 2° to avoid redundant rebuilds. + if (_currentHeading == null || (newHeading - _currentHeading!).abs() > 2) { + _currentHeading = newHeading; + notifyListeners(); + } + }); + } catch (_) { + // Compass not available on this device + } + + return null; + } + + void stopExploration() { + _isActive = false; + _activeDestination = null; + _nearbyHazard = null; + _positionSub?.cancel(); + _compassSub?.cancel(); + _cancelPeriodicGuidance(); + _ttsService.stop(); + notifyListeners(); + } + + Future setDestination(Place place) async { + _activeDestination = place; + _announcedHazardIds.clear(); + _currentStepIndex = 0; + _simulatedWalkDistance = 0; + _isRouting = true; + + // Ensure GPS tracking and compass are actively running + if (!_isActive) { + await startExploration(); + } + + final startLat = currentEffectiveLat; + final startLng = currentEffectiveLng; + final endLat = place.latitude; + final endLng = place.longitude; + + final rawDist = Geolocator.distanceBetween(startLat, startLng, endLat, endLng); + final bearing = Geolocator.bearingBetween(startLat, startLng, endLat, endLng); + final cardinal = _getCompassDirection(bearing); + + // Initial immediate route polyline and navigation steps (for instant visual feedback) + _navigationSteps = _generateNavigationSteps( + startLat, + startLng, + endLat, + endLng, + place.name, + rawDist, + cardinal, + ); + _buildRoutePolyline(startLat, startLng, endLat, endLng); + + notifyListeners(); + + // Start 15-second periodic stationary guidance reminder + _startPeriodicGuidanceTimer(); + + // Fetch real accessible walking route from OSRM pedestrian API + _fetchRealWalkingRoute(startLat, startLng, endLat, endLng, place.name, rawDist); + + _checkDestinationProximity(); + _checkHazardProximity(); + } + + Future _fetchRealWalkingRoute( + double startLat, + double startLng, + double endLat, + double endLng, + String destName, + double rawDist, + ) async { + try { + final origin = LatLng(startLat, startLng); + final destination = LatLng(endLat, endLng); + final osrmRoute = await OsrmRoutingService.getRoute( + origin, + destination, + profile: _currentRoutingProfile, + ); + + // Verify active destination hasn't changed or been cleared while fetching + if (_activeDestination == null || + _activeDestination!.latitude != endLat || + _activeDestination!.longitude != endLng) { + return; + } + + if (osrmRoute != null && osrmRoute.fullPolyline.isNotEmpty) { + _routePolyline = osrmRoute.fullPolyline; + + if (osrmRoute.steps.isNotEmpty) { + _navigationSteps = osrmRoute.steps.map((s) { + IconData icon = Icons.straight; + final dir = s.maneuverDirection.toLowerCase(); + final type = s.maneuverType.toLowerCase(); + + if (type == 'arrive') { + icon = Icons.place; + } else if (type == 'roundabout' || type == 'rotary') { + icon = Icons.roundabout_right; + } else if (dir.contains('slight left')) { + icon = Icons.turn_slight_left; + } else if (dir.contains('sharp left')) { + icon = Icons.turn_sharp_left; + } else if (dir.contains('left')) { + icon = Icons.turn_left; + } else if (dir.contains('slight right')) { + icon = Icons.turn_slight_right; + } else if (dir.contains('sharp right')) { + icon = Icons.turn_sharp_right; + } else if (dir.contains('right')) { + icon = Icons.turn_right; + } else if (dir.contains('uturn')) { + icon = Icons.u_turn_left; + } + + return NavigationStep( + instruction: s.instruction, + roadName: s.roadName, + icon: icon, + distanceMeters: s.distanceMeters, + turnType: s.maneuverType, + point: s.location, + bearingAfter: s.bearingAfter, + ); + }).toList(); + } + + _isRouting = false; + notifyListeners(); + + if (_ttsService.isEnabled) { + final distLabel = osrmRoute.totalDistanceMeters >= 1000 + ? '${(osrmRoute.totalDistanceMeters / 1000).toStringAsFixed(1)} kilometers' + : '${osrmRoute.totalDistanceMeters.round()} meters'; + final firstInstr = _navigationSteps.isNotEmpty + ? _navigationSteps[0].instruction + : 'Proceed along route'; + _ttsService.speak('${_currentRoutingProfile.displayName} route found to $destName. Total distance $distLabel. $firstInstr.'); + } + return; + } + } catch (_) {} + + _isRouting = false; + notifyListeners(); + + if (_ttsService.isEnabled) { + final distLabel = rawDist >= 1000 + ? '${(rawDist / 1000).toStringAsFixed(1)} kilometers' + : '${rawDist.round()} meters'; + final firstInstr = _navigationSteps.isNotEmpty ? _navigationSteps[0].instruction : 'Head forward'; + _ttsService.speak('Starting navigation to $destName. Total distance is $distLabel. $firstInstr.'); + } + } + + void _startPeriodicGuidanceTimer() { + _periodicGuidanceTimer?.cancel(); + _lastGuidancePosition = LatLng(currentEffectiveLat, currentEffectiveLng); + + _periodicGuidanceTimer = Timer.periodic(const Duration(seconds: 15), (_) { + _checkPeriodicStationaryGuidance(); + }); + } + + void _cancelPeriodicGuidance() { + _periodicGuidanceTimer?.cancel(); + _periodicGuidanceTimer = null; + _lastGuidancePosition = null; + } + + void _checkPeriodicStationaryGuidance() { + if (_activeDestination == null) { + _cancelPeriodicGuidance(); + return; + } + + final curLat = currentEffectiveLat; + final curLng = currentEffectiveLng; + + if (_lastGuidancePosition != null) { + final distMoved = Geolocator.distanceBetween( + _lastGuidancePosition!.latitude, + _lastGuidancePosition!.longitude, + curLat, + curLng, + ); + + // If user moved more than 8 meters and speed > 0.6 m/s, user is actively moving + if (distMoved > 8.0 && (currentSpeedMs ?? 0) > 0.6) { + _lastGuidancePosition = LatLng(curLat, curLng); + return; + } + } + + _lastGuidancePosition = LatLng(curLat, curLng); + + LatLng targetPoint; + double remainingDist; + + if (_navigationSteps.isNotEmpty && _currentStepIndex < _navigationSteps.length) { + final step = _navigationSteps[_currentStepIndex]; + targetPoint = step.point; + final stepD = Geolocator.distanceBetween(curLat, curLng, targetPoint.latitude, targetPoint.longitude); + remainingDist = step.distanceMeters > 0 ? step.distanceMeters : stepD; + } else { + targetPoint = LatLng(_activeDestination!.latitude, _activeDestination!.longitude); + remainingDist = Geolocator.distanceBetween(curLat, curLng, targetPoint.latitude, targetPoint.longitude); + } + + if (remainingDist < 8) return; + + final targetBearing = Geolocator.bearingBetween( + curLat, + curLng, + targetPoint.latitude, + targetPoint.longitude, + ); + + final distLabel = remainingDist >= 1000 + ? '${(remainingDist / 1000).toStringAsFixed(1)} kilometers' + : '${remainingDist.round()} meters'; + + String speech; + if (_currentHeading != null) { + double diff = (targetBearing - _currentHeading!) % 360; + if (diff > 180) diff -= 360; + if (diff < -180) diff += 360; + + final absDiff = diff.abs().round(); + if (absDiff <= 12) { + speech = 'Head straight ahead and walk $distLabel.'; + } else if (diff > 0) { + speech = 'Turn $absDiff degrees right, then walk $distLabel.'; + } else { + speech = 'Turn $absDiff degrees left, then walk $distLabel.'; + } + } else { + final cardinal = _getCompassDirection(targetBearing); + speech = 'Head $cardinal and walk $distLabel.'; + } + + if (_ttsService.isEnabled) { + _ttsService.speak(speech); + } + } + + void clearDestination() { + _activeDestination = null; + _nearbyHazard = null; + _navigationSteps = []; + _currentStepIndex = 0; + _simulatedWalkDistance = 0; + _routePolyline = []; + _cancelPeriodicGuidance(); + _isActive = false; + notifyListeners(); + if (_ttsService.isEnabled) { + _ttsService.speak('Navigation cancelled.'); + } + } + + /// Allows manual or simulated stepping forward along the route for testing + void stepAheadSimulated([double meters = 30.0]) { + if (_activeDestination == null || _navigationSteps.isEmpty) return; + + _simulatedWalkDistance += meters; + + if (_currentStepIndex < _navigationSteps.length) { + final current = _navigationSteps[_currentStepIndex]; + current.distanceMeters = (current.distanceMeters - meters).clamp(0.0, 999999.0); + + if (current.distanceMeters <= 5 && _currentStepIndex + 1 < _navigationSteps.length) { + _currentStepIndex++; + final next = _navigationSteps[_currentStepIndex]; + _triggerHaptic(10); + if (_ttsService.isEnabled) { + _ttsService.speak('${next.instruction}.'); + } + } else if (current.distanceMeters > 5 && current.distanceMeters <= 30) { + if (_ttsService.isEnabled) { + _ttsService.speak('In ${current.distanceMeters.round()} meters, prepare for maneuver.'); + } + } + } + + _checkDestinationProximity(); + notifyListeners(); + } + + 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'); + } + + /// Community vote: hazard is **still blocked**. Awards 5 pts. + void voteHazardBlocked(String hazardId) { + final index = _hazards.indexWhere((h) => h.id == hazardId); + if (index == -1) return; + _hazards[index].voteBlocked(); + notifyListeners(); + if (_ttsService.isEnabled) { + _ttsService.speak('Hazard confirmed still active. Thank you.'); + } + onContributionEarned?.call(5, 'Confirmed ${_hazards[index].category.label} still present'); + } + + /// Community vote: hazard is **fixed / cleared**. + /// Awards 10 pts for voting, +15 bonus pts if community consensus resolves it. + void voteHazardFixed(String hazardId) { + final index = _hazards.indexWhere((h) => h.id == hazardId); + if (index == -1) return; + final hazard = _hazards[index]; + final wasResolved = hazard.isResolved; + hazard.voteFixed(); + final justResolved = !wasResolved && hazard.isResolved; + if (justResolved) { + // Auto-resolved by community consensus + if (_nearbyHazard?.id == hazardId) _nearbyHazard = null; + if (_ttsService.isEnabled) { + _ttsService.speak('Community resolved! ${hazard.category.label} marked cleared. Path is now safe.'); + } + // Award resolution bonus on top of the vote points + onContributionEarned?.call(10, 'Voted to clear ${hazard.category.label}'); + onContributionEarned?.call(15, 'Resolved ${hazard.category.label} — community consensus bonus'); + } else { + if (_ttsService.isEnabled) { + _ttsService.speak('Vote recorded. Thank you for helping keep paths safe.'); + } + onContributionEarned?.call(10, 'Voted ${hazard.category.label} as fixed'); + } + notifyListeners(); + } + + // Legacy aliases kept for any remaining call-sites + void verifyHazardStillBlocked(String id) => voteHazardBlocked(id); + void markHazardFixed(String id) => voteHazardFixed(id); + + 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 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}. Step-free entrance is directly ahead.'); + } + _triggerHaptic(distance, arrival: true); + _cancelPeriodicGuidance(); + _activeDestination = null; + _navigationSteps = []; + _currentStepIndex = 0; + _routePolyline = []; + notifyListeners(); + return; + } + + // Outdoor GPS waypoint progression + if (_navigationSteps.isNotEmpty && _currentStepIndex < _navigationSteps.length) { + final step = _navigationSteps[_currentStepIndex]; + final distToStepPoint = Geolocator.distanceBetween( + currentEffectiveLat, + currentEffectiveLng, + step.point.latitude, + step.point.longitude, + ); + + // Decrement step distance to match real GPS position if walking outdoors + if (_simulatedWalkDistance == 0) { + step.distanceMeters = distToStepPoint; + } + + if (distToStepPoint < 15 && _currentStepIndex + 1 < _navigationSteps.length) { + _currentStepIndex++; + final next = _navigationSteps[_currentStepIndex]; + _triggerHaptic(10); + if (_ttsService.isEnabled) { + _ttsService.speak('${next.instruction}.'); + } + } + } + } + + List _generateNavigationSteps( + double startLat, + double startLng, + double endLat, + double endLng, + String destName, + double totalDist, + String cardinal, + ) { + if (totalDist < 120) { + return [ + NavigationStep( + instruction: 'Head $cardinal along accessible path for ${totalDist.round()} m', + roadName: 'Accessible Pathway', + icon: Icons.straight, + distanceMeters: totalDist, + turnType: 'straight', + point: LatLng(endLat, endLng), + ), + NavigationStep( + instruction: 'Arrive at $destName (Step-free entrance)', + roadName: destName, + icon: Icons.place, + distanceMeters: 0, + turnType: 'arrive', + point: LatLng(endLat, endLng), + ), + ]; + } + + final leg1 = (totalDist * 0.15).clamp(30.0, 300.0); + final leg2 = (totalDist * 0.25).clamp(50.0, 800.0); + final leg3 = (totalDist * 0.40).clamp(60.0, 2000.0); + final leg4 = (totalDist - leg1 - leg2 - leg3).clamp(30.0, 500.0); + + final p1 = LatLng( + startLat + (endLat - startLat) * 0.15, + startLng + (endLng - startLng) * 0.05, + ); + final p2 = LatLng( + startLat + (endLat - startLat) * 0.40, + startLng + (endLng - startLng) * 0.45, + ); + final p3 = LatLng( + startLat + (endLat - startLat) * 0.85, + startLng + (endLng - startLng) * 0.85, + ); + final pEnd = LatLng(endLat, endLng); + + return [ + NavigationStep( + instruction: 'Head $cardinal on accessible sidewalk for ${leg1.round()} m', + roadName: 'Main Sidewalk', + icon: Icons.straight, + distanceMeters: leg1, + turnType: 'straight', + point: p1, + ), + NavigationStep( + instruction: 'Turn right at pedestrian crossing and continue for ${leg2.round()} m', + roadName: 'Accessible Crossing', + icon: Icons.turn_right, + distanceMeters: leg2, + turnType: 'turnRight', + point: p2, + ), + NavigationStep( + instruction: 'Continue straight along the transit corridor for ${leg3.round()} m', + roadName: 'Transit Corridor', + icon: Icons.straight, + distanceMeters: leg3, + turnType: 'straight', + point: p3, + ), + NavigationStep( + instruction: 'Turn slightly left toward $destName entrance for ${leg4.round()} m', + roadName: 'Entrance Way', + icon: Icons.turn_slight_left, + distanceMeters: leg4, + turnType: 'slightLeft', + point: pEnd, + ), + NavigationStep( + instruction: 'Arrive at $destName (Step-free entrance directly ahead)', + roadName: destName, + icon: Icons.place, + distanceMeters: 0, + turnType: 'arrive', + point: pEnd, + ), + ]; + } + + void _buildRoutePolyline(double startLat, double startLng, double endLat, double endLng) { + final start = LatLng(startLat, startLng); + final end = LatLng(endLat, endLng); + final dLat = endLat - startLat; + final dLng = endLng - startLng; + + _routePolyline = [ + start, + LatLng(startLat + dLat * 0.15, startLng + dLng * 0.05), + LatLng(startLat + dLat * 0.25, startLng + dLng * 0.20), + LatLng(startLat + dLat * 0.40, startLng + dLng * 0.45), + LatLng(startLat + dLat * 0.65, startLng + dLng * 0.70), + LatLng(startLat + dLat * 0.85, startLng + dLng * 0.85), + end, + ]; + } + + 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(); + } +} + +class NavigationStep { + final String instruction; + final String roadName; + final IconData icon; + double distanceMeters; + final String turnType; + final LatLng point; + final double? bearingAfter; + + /// End-point lat/lng of this step — used for bearing calculation + double? get endLat => point.latitude; + double? get endLng => point.longitude; + + NavigationStep({ + required this.instruction, + required this.roadName, + required this.icon, + required this.distanceMeters, + required this.turnType, + required this.point, + this.bearingAfter, + }); +} diff --git a/lib/core/services/geocoding_service.dart b/lib/core/services/geocoding_service.dart index c6d37d9..c41c4ce 100644 --- a/lib/core/services/geocoding_service.dart +++ b/lib/core/services/geocoding_service.dart @@ -62,18 +62,52 @@ class GeocodingService { /// Returns null when permission is denied or location is unavailable. Future currentPosition() async { try { + final serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + final lastPos = await Geolocator.getLastKnownPosition(); + if (lastPos != null) { + final address = await reverseGeocode(lastPos.latitude, lastPos.longitude); + return GeoSelection( + latitude: lastPos.latitude, + longitude: lastPos.longitude, + address: address, + ); + } + } + var permission = await Geolocator.checkPermission(); - if (permission == LocationPermission.denied || - permission == LocationPermission.deniedForever) { + if (permission == LocationPermission.denied) { permission = await Geolocator.requestPermission(); } if (permission == LocationPermission.denied || permission == LocationPermission.deniedForever) { + final lastPos = await Geolocator.getLastKnownPosition(); + if (lastPos != null) { + final address = await reverseGeocode(lastPos.latitude, lastPos.longitude); + return GeoSelection( + latitude: lastPos.latitude, + longitude: lastPos.longitude, + address: address, + ); + } return null; } - final position = await Geolocator.getCurrentPosition( - locationSettings: const LocationSettings(accuracy: LocationAccuracy.medium), - ); + + Position? position; + try { + position = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.medium, + timeLimit: Duration(seconds: 5), + ), + ); + } catch (_) { + position = await Geolocator.getLastKnownPosition(); + } + + position ??= await Geolocator.getLastKnownPosition(); + if (position == null) return null; + final address = await reverseGeocode(position.latitude, position.longitude); return GeoSelection( latitude: position.latitude, @@ -81,6 +115,17 @@ class GeocodingService { address: address, ); } catch (_) { + try { + final lastPos = await Geolocator.getLastKnownPosition(); + if (lastPos != null) { + final address = await reverseGeocode(lastPos.latitude, lastPos.longitude); + return GeoSelection( + latitude: lastPos.latitude, + longitude: lastPos.longitude, + address: address, + ); + } + } catch (_) {} return null; } } 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/osrm_routing_service.dart b/lib/core/services/osrm_routing_service.dart new file mode 100644 index 0000000..971a9df --- /dev/null +++ b/lib/core/services/osrm_routing_service.dart @@ -0,0 +1,266 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:latlong2/latlong.dart'; + +/// Supported transportation profiles for OSRM routing. +enum OsrmProfile { + walk, + bike, + car; + + String get endpoint { + switch (this) { + case OsrmProfile.car: + return 'driving'; + case OsrmProfile.bike: + return 'bike'; + case OsrmProfile.walk: + return 'foot'; + } + } + + String get displayName { + switch (this) { + case OsrmProfile.car: + return 'Car'; + case OsrmProfile.bike: + return 'Bike'; + case OsrmProfile.walk: + return 'Walk'; + } + } + + IconData get icon { + switch (this) { + case OsrmProfile.car: + return Icons.directions_car; + case OsrmProfile.bike: + return Icons.directions_bike; + case OsrmProfile.walk: + return Icons.directions_walk; + } + } + + /// Estimated baseline speed in meters per minute (if device GPS speed is stationary/unknown) + double get defaultSpeedMPerMin { + switch (this) { + case OsrmProfile.car: + return 500.0; // ~30 km/h city driving + case OsrmProfile.bike: + return 250.0; // ~15 km/h cycling + case OsrmProfile.walk: + return 75.0; // ~4.5 km/h walking + } + } +} + +/// A single turn-by-turn navigation step decoded from OSRM. +class OsrmStep { + final String instruction; + final String roadName; + final double distanceMeters; + final double durationSeconds; + final String maneuverType; // 'turn', 'depart', 'arrive', 'straight', etc. + final String maneuverDirection; // 'left', 'right', 'straight', 'slight left', etc. + final double bearingBefore; // degrees, bearing you're coming from + final double bearingAfter; // degrees, bearing to head after maneuver + final LatLng location; + final List geometry; // sub-polyline for this step + + const OsrmStep({ + required this.instruction, + required this.roadName, + required this.distanceMeters, + required this.durationSeconds, + required this.maneuverType, + required this.maneuverDirection, + required this.bearingBefore, + required this.bearingAfter, + required this.location, + required this.geometry, + }); +} + +/// Full route returned by OSRM. +class OsrmRoute { + final List fullPolyline; + final List steps; + final double totalDistanceMeters; + final double totalDurationSeconds; + + const OsrmRoute({ + required this.fullPolyline, + required this.steps, + required this.totalDistanceMeters, + required this.totalDurationSeconds, + }); +} + +/// Calls the OSRM public API with 'foot', 'bike', or 'driving' profiles. +/// No API key required. +class OsrmRoutingService { + static const String _baseRouteUrl = 'https://router.project-osrm.org/route/v1'; + + /// Fetch a route from [origin] to [destination] using [profile] (walk, bike, car). + /// Returns null on any network/parse error. + static Future getRoute( + LatLng origin, + LatLng destination, { + OsrmProfile profile = OsrmProfile.walk, + }) async { + try { + final url = Uri.parse( + '$_baseRouteUrl/${profile.endpoint}/${origin.longitude},${origin.latitude};' + '${destination.longitude},${destination.latitude}' + '?overview=full&geometries=geojson&steps=true&annotations=false', + ); + + final response = + await http.get(url).timeout(const Duration(seconds: 12)); + + if (response.statusCode != 200) return null; + + final data = jsonDecode(response.body) as Map; + if (data['code'] != 'Ok') return null; + + final routes = data['routes'] as List; + if (routes.isEmpty) return null; + + final route = routes.first as Map; + final totalDistance = (route['distance'] as num).toDouble(); + final totalDuration = (route['duration'] as num).toDouble(); + + // Full polyline from GeoJSON + final geoCoords = + (route['geometry']['coordinates'] as List); + final fullPolyline = geoCoords + .map((c) { + final coords = c as List; + return LatLng( + (coords[1] as num).toDouble(), + (coords[0] as num).toDouble()); + }) + .toList(); + + // Steps + final legs = route['legs'] as List; + final steps = []; + for (final leg in legs) { + final legSteps = leg['steps'] as List; + for (final step in legSteps) { + final stepMap = step as Map; + final maneuver = stepMap['maneuver'] as Map; + final name = (stepMap['name'] as String?) ?? ''; + final dist = (stepMap['distance'] as num).toDouble(); + final dur = (stepMap['duration'] as num).toDouble(); + final mType = (maneuver['type'] as String?) ?? 'straight'; + final mDir = (maneuver['modifier'] as String?) ?? 'straight'; + final bBefore = (maneuver['bearing_before'] as num?)?.toDouble() ?? 0.0; + final bAfter = (maneuver['bearing_after'] as num?)?.toDouble() ?? 0.0; + final locCoords = + maneuver['location'] as List; + final loc = LatLng( + (locCoords[1] as num).toDouble(), + (locCoords[0] as num).toDouble()); + + // Step geometry + final stepGeoCoords = + (stepMap['geometry']['coordinates'] as List); + final stepGeometry = stepGeoCoords + .map((c) { + final coords = c as List; + return LatLng( + (coords[1] as num).toDouble(), + (coords[0] as num).toDouble()); + }) + .toList(); + + final instruction = _buildInstruction(mType, mDir, dist, name); + + steps.add(OsrmStep( + instruction: instruction, + roadName: name.isNotEmpty ? name : 'unnamed path', + distanceMeters: dist, + durationSeconds: dur, + maneuverType: mType, + maneuverDirection: mDir, + bearingBefore: bBefore, + bearingAfter: bAfter, + location: loc, + geometry: stepGeometry, + )); + } + } + + return OsrmRoute( + fullPolyline: fullPolyline, + steps: steps, + totalDistanceMeters: totalDistance, + totalDurationSeconds: totalDuration, + ); + } catch (e) { + // Network unavailable or parse error — caller falls back to straight line + return null; + } + } + + /// Convenience helper for walking route. + static Future getWalkingRoute(LatLng origin, LatLng destination) => + getRoute(origin, destination, profile: OsrmProfile.walk); + + /// Builds a human-readable instruction from OSRM maneuver data. + static String _buildInstruction( + String type, String direction, double distMeters, String name) { + final dist = distMeters >= 1000 + ? '${(distMeters / 1000).toStringAsFixed(1)} km' + : '${distMeters.round()} m'; + + final road = name.isNotEmpty ? 'onto $name' : ''; + + switch (type) { + case 'depart': + return 'Head ${_dirLabel(direction)} $road for $dist'; + case 'arrive': + return 'Arrive at destination'; + case 'turn': + return 'Turn ${_dirLabel(direction)} $road — $dist'; + case 'new name': + return 'Continue ${_dirLabel(direction)} $road for $dist'; + case 'merge': + return 'Merge ${_dirLabel(direction)} $road — $dist'; + case 'fork': + return 'Take ${_dirLabel(direction)} fork $road — $dist'; + case 'end of road': + return 'At end of road, turn ${_dirLabel(direction)} $road — $dist'; + case 'continue': + return 'Continue ${_dirLabel(direction)} for $dist'; + case 'roundabout': + case 'rotary': + return 'Enter roundabout, take exit ${_dirLabel(direction)} — $dist'; + default: + return 'Continue for $dist'; + } + } + + static String _dirLabel(String direction) { + switch (direction) { + case 'left': + return 'left'; + case 'right': + return 'right'; + case 'slight left': + return 'slightly left'; + case 'slight right': + return 'slightly right'; + case 'sharp left': + return 'sharp left'; + case 'sharp right': + return 'sharp right'; + case 'uturn': + return 'U-turn'; + default: + return 'straight'; + } + } +} diff --git a/lib/core/services/profile_storage_service.dart b/lib/core/services/profile_storage_service.dart new file mode 100644 index 0000000..9e9c024 --- /dev/null +++ b/lib/core/services/profile_storage_service.dart @@ -0,0 +1,132 @@ +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, + 'locationCount': profile.locationCount, + '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, + locationCount: map['locationCount'] 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..ad24c8b --- /dev/null +++ b/lib/core/services/tts_service.dart @@ -0,0 +1,98 @@ +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; + final langPrefix = _currentLanguage.split('-').first.toLowerCase(); + for (var voice in availableVoices) { + final loc = voice["locale"]?.toLowerCase() ?? ""; + if (loc.startsWith(langPrefix)) { + String name = voice["name"]?.toLowerCase() ?? ""; + if (name.contains("network") || name.contains("sfg") || name.contains("premium")) { + bestVoice = voice; + break; + } + bestVoice ??= voice; + } + } + + 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; + await init(); + final isAvailable = await _flutterTts.isLanguageAvailable(langCode) as bool? ?? false; + if (isAvailable) { + await _flutterTts.setLanguage(langCode); + try { + final voices = await _flutterTts.getVoices; + if (voices != null) { + final prefix = langCode.split('-').first.toLowerCase(); + for (var voice in voices) { + final vMap = Map.from(voice); + final loc = vMap["locale"]?.toLowerCase() ?? ""; + if (loc.startsWith(prefix)) { + await _flutterTts.setVoice({"name": vMap["name"]!, "locale": vMap["locale"]!}); + break; + } + } + } + } catch (_) {} + } 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/contribute/presentation/add_location_screen.dart b/lib/features/contribute/presentation/add_location_screen.dart index b4d1507..29d5537 100644 --- a/lib/features/contribute/presentation/add_location_screen.dart +++ b/lib/features/contribute/presentation/add_location_screen.dart @@ -981,25 +981,48 @@ class _MapPickerCardState extends State<_MapPickerCard> { } } + @override + void didUpdateWidget(covariant _MapPickerCard oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.initialSelection != null && + (oldWidget.initialSelection == null || + oldWidget.initialSelection!.latitude != widget.initialSelection!.latitude || + oldWidget.initialSelection!.longitude != widget.initialSelection!.longitude)) { + final target = LatLng( + widget.initialSelection!.latitude, + widget.initialSelection!.longitude, + ); + setState(() { + _picked = target; + _lastKnownCenter = target; + }); + try { + _mapController.move(target, 16.5); + } catch (_) {} + } + } + + Future _confirmAtPoint(LatLng point) async { + setState(() => _picked = point); + final address = await context.read().geocodingService.reverseGeocode( + point.latitude, + point.longitude, + ); + widget.onConfirmed(GeoSelection( + latitude: point.latitude, + longitude: point.longitude, + address: address, + )); + } + Future _confirm() async { LatLng center; try { center = _mapController.camera.center; } catch (_) { - // Controller not linked yet (widget rendered but camera unavailable) — - // use the last known position from onPositionChanged instead. center = _lastKnownCenter; } - setState(() => _picked = center); - final address = await context.read().geocodingService.reverseGeocode( - center.latitude, - center.longitude, - ); - widget.onConfirmed(GeoSelection( - latitude: center.latitude, - longitude: center.longitude, - address: address, - )); + await _confirmAtPoint(center); } @override @@ -1021,6 +1044,12 @@ class _MapPickerCardState extends State<_MapPickerCard> { interactionOptions: const InteractionOptions( flags: InteractiveFlag.drag | InteractiveFlag.pinchZoom, ), + onTap: (_, point) { + _confirmAtPoint(point); + try { + _mapController.move(point, _mapController.camera.zoom); + } catch (_) {} + }, onMapEvent: (event) { if (event.camera.center != _lastKnownCenter) { setState(() => _lastKnownCenter = event.camera.center); diff --git a/lib/features/contributions/presentation/contributions_screen.dart b/lib/features/contributions/presentation/contributions_screen.dart index 5855720..ce849dc 100644 --- a/lib/features/contributions/presentation/contributions_screen.dart +++ b/lib/features/contributions/presentation/contributions_screen.dart @@ -8,126 +8,491 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; -/// Contribute tab — the community hub. Primary action: add a new location. -class ContributionsScreen extends StatelessWidget { +/// Contribute tab — the community hub with live feed, missions, and add location. +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 state = context.watch(); - final profile = state.profile; + final profile = context.watch().profile; + final points = profile.communityPoints; + + // Tier ladder + const tiers = [ + (0, 'Bronze Explorer', 100), + (100, 'Silver Scout', 250), + (250, 'Gold Guide', 500), + (500, 'Platinum Champion', 1000), + (1000, 'Community Hero', 9999), + ]; + final tierData = tiers.lastWhere((t) => points >= t.$1, orElse: () => tiers.first); + final tierName = tierData.$2; + final nextTierPoints = tierData.$3; + final tierStart = tierData.$1; + final progress = nextTierPoints == 9999 + ? 1.0 + : ((points - tierStart) / (nextTierPoints - tierStart)).clamp(0.0, 1.0); + final ptsToNext = nextTierPoints == 9999 ? 0 : nextTierPoints - points; + return Scaffold( - appBar: AppBar(title: const Text('Contribute')), - 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: [ - Text( - 'Help make places more accessible', - style: AppTypography.headlineSmall, - ), - const SizedBox(height: AppSpacing.xs), - Text( - 'Every contribution helps someone plan a visit with confidence.', - style: AppTypography.bodyMedium.copyWith(color: AppColors.textSecondary), - ), - const SizedBox(height: AppSpacing.lg), - _ActionCard( - key: const ValueKey('add-location-card'), - icon: Icons.add_location_alt, - title: 'Add a New Location', - subtitle: 'Put an accessible place on the community map', - highlight: true, - onTap: () => _openAddLocation(context), - ), - _ActionCard( - icon: Icons.fact_check_outlined, - title: 'Add Accessibility Information', - subtitle: 'Confirm or update what you know about a place', - onTap: () => _openAddLocation(context), - ), - _ActionCard( - icon: Icons.rate_review_outlined, - title: 'Write a Review', - subtitle: 'Share how a place worked for you', - onTap: () => _showPickPlaceSheet( - context, - (place) => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => WriteReviewScreen(place: place), + // 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), + ), + ], + ), + 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: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.workspace_premium, color: Colors.amber, size: 20), + const SizedBox(width: 4), + Text( + tierName, + style: const 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( + ptsToNext == 0 + ? 'Max tier reached! 🏆' + : '$ptsToNext pts to next tier', + style: const TextStyle( + color: Colors.amberAccent, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.end, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], ), ), - ), - ), - _ActionCard( - icon: Icons.verified_outlined, - title: 'Confirm Existing Information', - subtitle: 'Verify accessibility features you have seen', - onTap: () => _showPickPlaceSheet( - context, - (place) => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => PlaceDetailsScreen(placeId: place.id), + const SizedBox(height: AppSpacing.lg), + // Metric counters + Row( + children: [ + Expanded( + child: _MetricTile( + label: 'Locations', + value: profile.locationCount, + icon: Icons.add_location_alt, + color: Colors.indigo, + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: _MetricTile( + label: 'Reviews', + value: profile.reviewCount, + icon: Icons.rate_review, + color: Colors.blue, + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: _MetricTile( + label: 'Updates', + value: profile.accessibilityUpdates, + icon: Icons.check_circle, + color: Colors.teal, + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: _MetricTile( + label: 'Photos', + value: profile.photoCount, + icon: Icons.photo_camera, + color: Colors.purple, + ), + ), + ], + ), + const SizedBox(height: AppSpacing.xl), + // Quick Contribution Actions + _ActionCard( + key: const ValueKey('add-location-card'), + icon: Icons.add_location_alt, + title: 'Add a New Location', + subtitle: 'Put an accessible place on the community map', + highlight: true, + onTap: () => _openAddLocation(context), + ), + _ActionCard( + icon: Icons.fact_check_outlined, + title: 'Add Accessibility Information', + subtitle: 'Confirm or update what you know about a place', + onTap: () => _openAddLocation(context), + ), + _ActionCard( + icon: Icons.rate_review_outlined, + title: 'Write a Review', + subtitle: 'Share how a place worked for you', + onTap: () => _showPickPlaceSheet( + context, + (place) => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => WriteReviewScreen(place: place), + ), + ), ), ), - ), - ), - const SizedBox(height: AppSpacing.xl), - 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, + _ActionCard( + icon: Icons.verified_outlined, + title: 'Confirm Existing Information', + subtitle: 'Verify accessibility features you have seen', + onTap: () => _showPickPlaceSheet( + context, + (place) => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => PlaceDetailsScreen(placeId: place.id), + ), ), ), - Text( - 'Community Points', - style: AppTypography.titleLarge.copyWith( - color: AppColors.textOnPrimary, + ), + 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: 'Locations', - value: profile.locationCount, - ), + + // 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, ), - const SizedBox(width: AppSpacing.md), - 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 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), + ], + ); + }, ), ], ), @@ -152,8 +517,8 @@ class ContributionsScreen extends StatelessWidget { child: ListView( shrinkWrap: true, children: [ - const Padding( - padding: EdgeInsets.all(AppSpacing.lg), + Padding( + padding: const EdgeInsets.all(AppSpacing.lg), child: Text('Choose a place', style: AppTypography.headlineSmall), ), ...places.map( @@ -244,15 +609,22 @@ class _ActionCard 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.md), + padding: const EdgeInsets.symmetric(vertical: AppSpacing.lg, horizontal: AppSpacing.sm), decoration: BoxDecoration( color: AppColors.surface, borderRadius: AppRadii.borderRadiusMd, @@ -260,10 +632,309 @@ class _MetricTile extends StatelessWidget { ), child: Column( children: [ - Text(value.toString(), style: AppTypography.headlineMedium), - Text(label, style: AppTypography.bodySmall, textAlign: TextAlign.center), + 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..ea0e0b3 --- /dev/null +++ b/lib/features/emergency/presentation/sos_emergency_screen.dart @@ -0,0 +1,314 @@ +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: Flexible( + child: Text( + 'CALL ${profile.emergencyContacts.first.name.toUpperCase()}', + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), + 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 Flexible( + child: Text( + 'SHOW OFFICIAL UDID / MEDICAL PASS', + style: TextStyle(color: Colors.amberAccent, fontWeight: FontWeight.bold), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), + ), + ), + ], + + // 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 3269b7a..110e0d9 100644 --- a/lib/features/map/data/mock_place_repository.dart +++ b/lib/features/map/data/mock_place_repository.dart @@ -88,10 +88,12 @@ 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)); await _ensureCommunityLoaded(); - 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 @@ -232,7 +234,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( @@ -240,7 +242,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( @@ -256,7 +258,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, ), ], @@ -298,7 +300,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( @@ -306,7 +308,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( @@ -314,7 +316,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, ), ], @@ -352,7 +354,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( @@ -360,7 +362,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, ), ], @@ -402,7 +404,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( @@ -410,7 +412,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( @@ -418,7 +420,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, ), ], @@ -457,7 +459,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( @@ -465,7 +467,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, ), ], @@ -506,7 +508,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( @@ -514,7 +516,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, ), ], @@ -551,7 +553,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( @@ -559,7 +561,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, ), ], @@ -596,7 +598,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, ), ], @@ -634,7 +636,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, ), ], @@ -671,7 +673,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, ), ], @@ -708,7 +710,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( @@ -716,7 +718,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, ), ], @@ -757,7 +759,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( @@ -765,11 +767,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 b41df15..79be011 100644 --- a/lib/features/map/presentation/discover_screen.dart +++ b/lib/features/map/presentation/discover_screen.dart @@ -1,11 +1,10 @@ import 'package:access_map/app/app_state.dart'; +import 'package:access_map/core/services/exploration_service.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/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/widgets/access_search_bar.dart'; -import 'package:access_map/shared/widgets/app_components.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -18,124 +17,140 @@ class DiscoverScreen extends StatefulWidget { class _DiscoverScreenState extends State { PlaceCategory? _selectedCategory; + final TextEditingController _searchController = TextEditingController(); + String _localQuery = ''; + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { final state = context.watch(); - final allPlaces = state.visiblePlaces; - final filtered = _selectedCategory == null - ? allPlaces - : allPlaces.where((p) => p.category == _selectedCategory).toList(); + final exploration = context.watch(); + + // Exclude the place currently being navigated to + final activeNavId = exploration.activeDestination?.id; + + final allPlaces = state.places + .where((p) => p.id != activeNavId) + .toList(); + + final filtered = allPlaces.where((p) { + final matchCat = + _selectedCategory == null || p.category == _selectedCategory; + final matchQ = _localQuery.isEmpty || + p.name.toLowerCase().contains(_localQuery.toLowerCase()) || + p.address.toLowerCase().contains(_localQuery.toLowerCase()); + return matchCat && matchQ; + }).toList(); + final popular = [...allPlaces] - ..sort((a, b) => b.communityConfirmations.compareTo(a.communityConfirmations)); + ..sort((a, b) => + b.communityConfirmations.compareTo(a.communityConfirmations)); + final highlyRated = [...allPlaces] ..sort((a, b) => b.friendlyScore.compareTo(a.friendlyScore)); - final recentlyAdded = allPlaces - .where((p) => p.source == PlaceSource.community) - .toList() - ..sort((a, b) => (b.createdAt ?? DateTime(2000)).compareTo(a.createdAt ?? DateTime(2000))); - final showingCategorySections = _selectedCategory == null; + + final recentlyAdded = + [...allPlaces.where((p) => p.source == PlaceSource.community)] + ..sort((a, b) => (b.createdAt ?? DateTime(2000)) + .compareTo(a.createdAt ?? DateTime(2000))); + + final isSearching = + _localQuery.isNotEmpty || _selectedCategory != null; return Scaffold( - appBar: AppBar(title: const Text('Discover')), - body: SafeArea( - child: RefreshIndicator( - onRefresh: context.read().loadPlaces, - child: ListView( - padding: const EdgeInsets.all(AppSpacing.lg), - children: [ - const AccessSearchBar(), - const SizedBox(height: AppSpacing.lg), - _ProfileLens(needs: state.profile.accessibilityNeeds), - const SizedBox(height: AppSpacing.lg), - SizedBox( - height: 44, - child: ListView( - scrollDirection: Axis.horizontal, - children: [ - _CategoryChip( - icon: Icons.apps, - label: 'All', - selected: _selectedCategory == null, - onTap: () => setState(() => _selectedCategory = null), - ), - ...PlaceCategory.values.map( - (category) => _CategoryChip( - icon: category.icon, - label: category.displayName, - selected: _selectedCategory == category, - onTap: () => setState(() => _selectedCategory = category), - ), - ), - ], + backgroundColor: AppColors.background, + body: NestedScrollView( + headerSliverBuilder: (context, _) => [ + SliverAppBar( + pinned: true, + floating: true, + snap: true, + expandedHeight: 130, + backgroundColor: AppColors.surface, + surfaceTintColor: Colors.transparent, + elevation: 0, + flexibleSpace: FlexibleSpaceBar( + background: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + AppColors.primary, + AppColors.primary.withValues(alpha: 0.75), + ], + ), ), - ), - const SizedBox(height: AppSpacing.lg), - if (state.isLoadingPlaces) - const Center(child: CircularProgressIndicator()) - else if (filtered.isEmpty) - const EmptyState( - title: 'No accessible places found nearby', - message: 'Try expanding your search or removing a filter.', - ) - else ...[ - if (showingCategorySections) ...[ - if (recentlyAdded.isNotEmpty) ...[ - const SectionHeader('Recently Added'), - ...recentlyAdded.take(3).map( - (place) => Padding( - padding: const EdgeInsets.only(bottom: AppSpacing.md), - child: PlaceCard( - place: place, - onTap: () => _openDetails(context, place), - ), + child: SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, AppSpacing.lg, AppSpacing.md, 52), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Discover', + style: TextStyle( + color: Colors.white, + fontSize: 26, + fontWeight: FontWeight.bold, ), ), - ], - const SectionHeader('Popular Near You'), - SizedBox( - height: 190, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: popular.take(6).length, - separatorBuilder: (_, __) => - const SizedBox(width: AppSpacing.md), - itemBuilder: (context, index) => SizedBox( - width: 250, - child: _CompactPlaceTile( - place: popular[index], - subtitle: - '${popular[index].communityConfirmations} confirmations', - ), - ), - ), - ), - const SectionHeader('Highly Rated by Community'), - ...highlyRated.take(5).map( - (place) => Padding( - padding: const EdgeInsets.only(bottom: AppSpacing.md), - child: PlaceCard( - place: place, - onTap: () => _openDetails(context, place), + Text( + activeNavId != null + ? '${allPlaces.length} places • Navigating excluded' + : '${allPlaces.length} accessible places near you', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.85), + fontSize: 13, ), ), - ), - ] else ...[ - const SectionHeader('Matching Places'), - ...filtered.map( - (place) => Padding( - padding: const EdgeInsets.only(bottom: AppSpacing.md), - child: PlaceCard( - place: place, - onTap: () => _openDetails(context, place), - ), + ], ), ), - ], - ], - ], + ), + ), + ), + bottom: PreferredSize( + preferredSize: const Size.fromHeight(52), + child: Container( + color: AppColors.surface, + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, 0, AppSpacing.md, AppSpacing.sm), + child: _SearchField( + controller: _searchController, + onChanged: (q) => setState(() => _localQuery = q), + ), + ), + ), ), - ), + ], + body: state.isLoadingPlaces + ? const Center(child: CircularProgressIndicator()) + : isSearching + ? _SearchResultsView( + places: filtered, + query: _localQuery, + selectedCategory: _selectedCategory, + onCategorySelected: (cat) => + setState(() => _selectedCategory = cat), + onPlaceTap: (p) => _openDetails(context, p), + ) + : _ExploreView( + allPlaces: allPlaces, + popular: popular, + highlyRated: highlyRated, + recentlyAdded: recentlyAdded, + onCategorySelected: (cat) => + setState(() => _selectedCategory = cat), + onPlaceTap: (p) => _openDetails(context, p), + profileNeeds: state.profile.accessibilityNeeds, + ), ), ); } @@ -143,113 +158,747 @@ class _DiscoverScreenState extends State { void _openDetails(BuildContext context, Place place) { Navigator.of(context).push( MaterialPageRoute( - builder: (_) => PlaceDetailsScreen(placeId: place.id), - ), + builder: (_) => PlaceDetailsScreen(placeId: place.id)), ); } } -class _CategoryChip extends StatelessWidget { - const _CategoryChip({ - required this.icon, - required this.label, - required this.selected, - required this.onTap, +// ───────────────────────────────────────────────────────────────────────────── +// EXPLORE VIEW (default browse) +// ───────────────────────────────────────────────────────────────────────────── +class _ExploreView extends StatelessWidget { + const _ExploreView({ + required this.allPlaces, + required this.popular, + required this.highlyRated, + required this.recentlyAdded, + required this.onCategorySelected, + required this.onPlaceTap, + required this.profileNeeds, }); - final IconData icon; - final String label; - final bool selected; - final VoidCallback onTap; + final List allPlaces; + final List popular; + final List highlyRated; + final List recentlyAdded; + final ValueChanged onCategorySelected; + final ValueChanged onPlaceTap; + final List profileNeeds; @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(right: AppSpacing.sm), - child: FilterChip( - avatar: Icon(icon, size: 18), - label: Text(label), - selected: selected, - showCheckmark: false, - onSelected: (_) => onTap(), - ), + return CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: _CategoryChipRow(onSelected: onCategorySelected)), + + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, vertical: AppSpacing.sm), + child: _ProfileLens(needs: profileNeeds), + ), + ), + + // ── Top Rated single featured card ───────────────────── + if (highlyRated.isNotEmpty) ...[ + _header('⭐ Top Rated Nearby'), + SliverPadding( + padding: + const EdgeInsets.symmetric(horizontal: AppSpacing.md), + sliver: SliverToBoxAdapter( + child: _FeaturedCard( + place: highlyRated.first, + onTap: () => onPlaceTap(highlyRated.first), + ), + ), + ), + ], + + // ── Popular horizontal ───────────────────────────────── + _header('🔥 Popular Near You'), + SliverToBoxAdapter( + child: SizedBox( + height: 160, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md), + itemCount: popular.take(8).length, + separatorBuilder: (_, __) => + const SizedBox(width: AppSpacing.sm), + itemBuilder: (_, i) => _HorizCard( + place: popular[i], + onTap: () => onPlaceTap(popular[i]), + ), + ), + ), + ), + + // ── Recently Added ───────────────────────────────────── + if (recentlyAdded.isNotEmpty) ...[ + _header('🆕 Recently Added by Community'), + SliverToBoxAdapter( + child: SizedBox( + height: 160, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md), + itemCount: recentlyAdded.take(6).length, + separatorBuilder: (_, __) => + const SizedBox(width: AppSpacing.sm), + itemBuilder: (_, i) => _HorizCard( + place: recentlyAdded[i], + onTap: () => onPlaceTap(recentlyAdded[i]), + badge: 'Community', + ), + ), + ), + ), + ], + + // ── Highly Accessible list ───────────────────────────── + _header('♿ Highly Accessible'), + SliverPadding( + padding: + const EdgeInsets.symmetric(horizontal: AppSpacing.md), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (_, i) { + final idx = (i + 1).clamp(0, highlyRated.length - 1); + return Padding( + padding: + const EdgeInsets.only(bottom: AppSpacing.sm), + child: _ListCard( + place: highlyRated[idx], + onTap: () => onPlaceTap(highlyRated[idx]), + ), + ); + }, + childCount: (highlyRated.length - 1).clamp(0, 6), + ), + ), + ), + + const SliverToBoxAdapter(child: SizedBox(height: 80)), + ], ); } + + SliverToBoxAdapter _header(String title) => SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, AppSpacing.lg, AppSpacing.md, AppSpacing.sm), + child: Text( + title, + style: AppTypography.titleLarge + .copyWith(fontWeight: FontWeight.bold), + ), + ), + ); } -class _CompactPlaceTile extends StatelessWidget { - const _CompactPlaceTile({required this.place, required this.subtitle}); +// ───────────────────────────────────────────────────────────────────────────── +// SEARCH RESULTS VIEW +// ───────────────────────────────────────────────────────────────────────────── +class _SearchResultsView extends StatelessWidget { + const _SearchResultsView({ + required this.places, + required this.query, + required this.selectedCategory, + required this.onCategorySelected, + required this.onPlaceTap, + }); + + final List places; + final String query; + final PlaceCategory? selectedCategory; + final ValueChanged onCategorySelected; + final ValueChanged onPlaceTap; + @override + Widget build(BuildContext context) { + return CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: _CategoryChipRow( + onSelected: onCategorySelected, selected: selectedCategory), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, AppSpacing.sm, AppSpacing.md, 0), + child: Text( + '${places.length} result${places.length == 1 ? '' : 's'}${query.isNotEmpty ? ' for "$query"' : ''}', + style: AppTypography.bodySmall + .copyWith(color: AppColors.textSecondary), + ), + ), + ), + if (places.isEmpty) + SliverFillRemaining( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.search_off, + size: 72, + color: AppColors.textSecondary + .withValues(alpha: 0.35)), + const SizedBox(height: AppSpacing.md), + Text('No places found', + style: AppTypography.titleMedium + .copyWith(fontWeight: FontWeight.bold)), + const SizedBox(height: AppSpacing.xs), + Text('Try a different search or category', + style: AppTypography.bodySmall.copyWith( + color: AppColors.textSecondary)), + ], + ), + ), + ) + else + SliverPadding( + padding: const EdgeInsets.all(AppSpacing.md), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (_, i) => Padding( + padding: + const EdgeInsets.only(bottom: AppSpacing.sm), + child: _ListCard( + place: places[i], + onTap: () => onPlaceTap(places[i]), + ), + ), + childCount: places.length, + ), + ), + ), + ], + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// CARDS — no external images, icon-based clean design +// ───────────────────────────────────────────────────────────────────────────── + +/// Large featured card — gradient background derived from category colour +class _FeaturedCard extends StatelessWidget { + const _FeaturedCard({required this.place, required this.onTap}); final Place place; - final String subtitle; + final VoidCallback onTap; @override Widget build(BuildContext context) { - return Card( - child: InkWell( - borderRadius: AppRadii.borderRadiusMd, - onTap: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => PlaceDetailsScreen(placeId: place.id), + final score = place.friendlyScore; + final scoreColor = AppColors.scoreColor(score); + final catColor = _categoryColor(place.category); + + return GestureDetector( + onTap: onTap, + child: Container( + height: 160, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + catColor, + catColor.withValues(alpha: 0.7), + ], ), + borderRadius: AppRadii.borderRadiusLg, + boxShadow: AppShadows.md, ), - child: Padding( - padding: const EdgeInsets.all(AppSpacing.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Large icon + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: AppRadii.borderRadiusMd, + ), + child: Icon(place.category.icon, + size: 36, color: Colors.white), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - CircleAvatar( - backgroundColor: AppColors.primarySurface, - foregroundColor: AppColors.primary, - child: Icon(place.category.icon, size: 20), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.white24, + borderRadius: AppRadii.borderRadiusFull, + ), + child: Text( + place.category.displayName, + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold), + ), + ), + const SizedBox(height: 6), + Text( + place.name, + style: const TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + place.address, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.85), + fontSize: 12, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + // Score + confirmations row + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: AppRadii.borderRadiusFull, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.accessible, + size: 12, color: scoreColor), + const SizedBox(width: 3), + Text( + score > 0 + ? '${score.toStringAsFixed(1)}/10' + : 'New', + style: TextStyle( + color: scoreColor, + fontSize: 11, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + Text( + '${place.communityConfirmations} verified', + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w600), + ), + ], ), - const Spacer(), - _ScoreBadge(score: place.friendlyScore), ], ), - const Spacer(), - Text( - place.name, - style: AppTypography.titleMedium, - maxLines: 1, - overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ); + } +} + +/// Compact horizontal-scroll card — category colour accent strip +class _HorizCard extends StatelessWidget { + const _HorizCard( + {required this.place, required this.onTap, this.badge}); + final Place place; + final VoidCallback onTap; + final String? badge; + + @override + Widget build(BuildContext context) { + final score = place.friendlyScore; + final scoreColor = AppColors.scoreColor(score); + final catColor = _categoryColor(place.category); + + return GestureDetector( + onTap: onTap, + child: Container( + width: 170, + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: AppRadii.borderRadiusMd, + boxShadow: AppShadows.sm, + border: Border.all(color: AppColors.divider), + ), + clipBehavior: Clip.antiAlias, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Colour accent strip with icon + Container( + height: 72, + width: double.infinity, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + catColor, + catColor.withValues(alpha: 0.65), + ], + ), ), - Text( - subtitle, - style: AppTypography.bodySmall, - maxLines: 1, - overflow: TextOverflow.ellipsis, + child: Stack( + children: [ + Center( + child: Icon(place.category.icon, + size: 36, color: Colors.white70), + ), + if (badge != null) + Positioned( + top: 6, + left: 6, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: AppColors.primary, + borderRadius: AppRadii.borderRadiusFull, + ), + child: Text( + badge!, + style: const TextStyle( + color: Colors.white, + fontSize: 9, + fontWeight: FontWeight.bold), + ), + ), + ), + Positioned( + top: 6, + right: 6, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 5, vertical: 2), + decoration: BoxDecoration( + color: scoreColor, + borderRadius: AppRadii.borderRadiusFull, + ), + child: Text( + score > 0 ? score.toStringAsFixed(1) : 'New', + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold), + ), + ), + ), + ], ), - ], - ), + ), + // Info + Padding( + padding: const EdgeInsets.all(AppSpacing.sm), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + place.name, + style: AppTypography.titleMedium + .copyWith(fontWeight: FontWeight.bold), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + place.category.displayName, + style: AppTypography.bodySmall + .copyWith(color: AppColors.textSecondary), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Row( + children: [ + Icon(Icons.people_alt, + size: 11, + color: AppColors.primary + .withValues(alpha: 0.7)), + const SizedBox(width: 3), + Text( + '${place.communityConfirmations} verified', + style: AppTypography.bodySmall.copyWith( + color: AppColors.primary, + fontWeight: FontWeight.w600), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +/// Full-width list card for search results and "Highly Accessible" section +class _ListCard extends StatelessWidget { + const _ListCard({required this.place, required this.onTap}); + final Place place; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final score = place.friendlyScore; + final scoreColor = AppColors.scoreColor(score); + final catColor = _categoryColor(place.category); + + return GestureDetector( + onTap: onTap, + child: Container( + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: AppRadii.borderRadiusMd, + boxShadow: AppShadows.sm, + border: Border.all(color: AppColors.divider), + ), + child: Row( + children: [ + // Square icon block + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + catColor, + catColor.withValues(alpha: 0.65), + ], + ), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(12), + bottomLeft: Radius.circular(12), + ), + ), + child: Icon(place.category.icon, + size: 32, color: Colors.white), + ), + // Info + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, vertical: AppSpacing.sm), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + child: Text( + place.name, + style: AppTypography.titleMedium + .copyWith(fontWeight: FontWeight.bold), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: scoreColor.withValues(alpha: 0.12), + borderRadius: AppRadii.borderRadiusFull, + border: Border.all( + color: + scoreColor.withValues(alpha: 0.4)), + ), + child: Text( + score > 0 + ? '${score.toStringAsFixed(1)} ♿' + : 'New', + style: TextStyle( + color: scoreColor, + fontSize: 11, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + const SizedBox(height: 2), + Text( + place.address, + style: AppTypography.bodySmall + .copyWith(color: AppColors.textSecondary), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Wrap( + spacing: 4, + runSpacing: 2, + children: place.availableFeatures + .take(2) + .map((f) => Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: const Color(0xFFD1FAE5), + borderRadius: + AppRadii.borderRadiusFull, + ), + child: Text( + f.name, + style: const TextStyle( + color: Color(0xFF065F46), + fontSize: 10, + fontWeight: FontWeight.w600, + ), + ), + )) + .toList(), + ), + ], + ), + ), + ), + const Icon(Icons.chevron_right, + color: AppColors.textSecondary, size: 20), + const SizedBox(width: 4), + ], ), ), ); } } -class _ScoreBadge extends StatelessWidget { - const _ScoreBadge({required this.score}); +// ───────────────────────────────────────────────────────────────────────────── +// SHARED SUBWIDGETS +// ───────────────────────────────────────────────────────────────────────────── - final double score; +class _SearchField extends StatelessWidget { + const _SearchField( + {required this.controller, required this.onChanged}); + final TextEditingController controller; + final ValueChanged onChanged; @override Widget build(BuildContext context) { - final color = AppColors.scoreColor(score); - return Container( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.sm, - vertical: AppSpacing.xs, + return TextField( + controller: controller, + onChanged: onChanged, + textInputAction: TextInputAction.search, + style: AppTypography.bodyMedium, + decoration: InputDecoration( + hintText: 'Search accessible places...', + hintStyle: AppTypography.bodyMedium + .copyWith(color: AppColors.textSecondary), + prefixIcon: + const Icon(Icons.search, color: AppColors.primary), + suffixIcon: controller.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear, size: 18), + onPressed: () { + controller.clear(); + onChanged(''); + }, + ) + : null, + filled: true, + fillColor: AppColors.surfaceVariant.withValues(alpha: 0.6), + border: OutlineInputBorder( + borderRadius: AppRadii.borderRadiusFull, + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, vertical: 0), + isDense: true, ), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.12), - borderRadius: AppRadii.borderRadiusFull, + ); + } +} + +class _CategoryChipRow extends StatelessWidget { + const _CategoryChipRow( + {required this.onSelected, this.selected}); + final ValueChanged onSelected; + final PlaceCategory? selected; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 48, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, vertical: 4), + children: [ + _Chip( + icon: Icons.apps_rounded, + label: 'All', + isSelected: selected == null, + onTap: () => onSelected(null), + ), + ...PlaceCategory.values.map((cat) => _Chip( + icon: cat.icon, + label: cat.displayName, + isSelected: selected == cat, + onTap: () => onSelected(cat), + )), + ], ), - child: Text( - score.toStringAsFixed(1), - style: AppTypography.labelMedium.copyWith(color: color), + ); + } +} + +class _Chip extends StatelessWidget { + const _Chip({ + required this.icon, + required this.label, + required this.isSelected, + required this.onTap, + }); + final IconData icon; + final String label; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(right: 6), + child: FilterChip( + avatar: Icon(icon, size: 15), + label: Text(label, style: const TextStyle(fontSize: 12)), + selected: isSelected, + showCheckmark: false, + onSelected: (_) => onTap(), + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 2), ), ); } @@ -257,27 +906,37 @@ class _ScoreBadge extends StatelessWidget { class _ProfileLens extends StatelessWidget { const _ProfileLens({required this.needs}); - final List needs; @override Widget build(BuildContext context) { return Container( - padding: const EdgeInsets.all(AppSpacing.lg), + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, vertical: AppSpacing.xs), decoration: BoxDecoration( - color: AppColors.primarySurface, + gradient: LinearGradient( + colors: [ + AppColors.primary.withValues(alpha: 0.08), + AppColors.primary.withValues(alpha: 0.03), + ], + ), borderRadius: AppRadii.borderRadiusMd, + border: Border.all( + color: AppColors.primary.withValues(alpha: 0.2)), ), child: Row( children: [ - const Icon(Icons.auto_awesome, color: AppColors.primary), - const SizedBox(width: AppSpacing.md), + const Icon(Icons.auto_awesome, + color: AppColors.primary, size: 18), + const SizedBox(width: AppSpacing.sm), Expanded( child: Text( needs.isEmpty - ? 'Showing community accessibility intelligence.' - : 'Prioritizing ${needs.map((need) => need.displayName).join(', ')} signals.', - style: AppTypography.bodyMedium, + ? 'Showing all accessible places nearby' + : 'Prioritising ${needs.map((n) => n.displayName).join(', ')} venues', + style: AppTypography.bodySmall.copyWith( + color: AppColors.primary, + fontWeight: FontWeight.w500), ), ), ], @@ -285,3 +944,42 @@ class _ProfileLens extends StatelessWidget { ); } } + +// ───────────────────────────────────────────────────────────────────────────── +// HELPERS +// ───────────────────────────────────────────────────────────────────────────── + +/// Returns a colour per category used as the card accent/gradient. +Color _categoryColor(PlaceCategory cat) { + switch (cat) { + case PlaceCategory.hospital: + case PlaceCategory.clinic: + return const Color(0xFFEF4444); + case PlaceCategory.pharmacy: + return const Color(0xFF10B981); + case PlaceCategory.restaurant: + return const Color(0xFFF97316); + case PlaceCategory.cafe: + return const Color(0xFF92400E); + case PlaceCategory.hotel: + return const Color(0xFF8B5CF6); + case PlaceCategory.park: + return const Color(0xFF16A34A); + case PlaceCategory.busStop: + return const Color(0xFF0EA5E9); + case PlaceCategory.college: + return const Color(0xFF6366F1); + case PlaceCategory.touristAttraction: + return const Color(0xFFD97706); + case PlaceCategory.mall: + return const Color(0xFFEC4899); + case PlaceCategory.governmentOffice: + return const Color(0xFF475569); + case PlaceCategory.communityCenter: + return const Color(0xFF0891B2); + case PlaceCategory.repairSupport: + return const Color(0xFF78716C); + case PlaceCategory.atm: + return const Color(0xFF15803D); + } +} diff --git a/lib/features/map/presentation/exploration_overlay.dart b/lib/features/map/presentation/exploration_overlay.dart new file mode 100644 index 0000000..708c88b --- /dev/null +++ b/lib/features/map/presentation/exploration_overlay.dart @@ -0,0 +1,1779 @@ +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/core/services/osrm_routing_service.dart'; +import 'package:access_map/features/places/presentation/place_details_screen.dart'; +import 'package:access_map/shared/models/hazard_report.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: SafeArea( + child: isNavigating + ? _buildGoogleMapsNavigationHUD(context, exploration, state) + : _buildCategoryExplorer(context, exploration, state), + ), + ); + }, + ); + } + + // ========================================== + // 1. CATEGORY AUDIO EXPLORER VIEW + // ========================================== + Widget _buildCategoryExplorer( + 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 & 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 & Leisure', + icon: Icons.nightlife, + color: const Color(0xFF9333EA), + filter: (p) => + p.category == PlaceCategory.touristAttraction || + p.category == PlaceCategory.hotel, + ), + _ExplorerCategory( + label: 'Dining & 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 Stack( + children: [ + // Top Floating Compass & Close Capsule + Positioned( + top: AppSpacing.sm, + left: AppSpacing.md, + right: AppSpacing.md, + child: Row( + children: [ + Expanded( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: AppRadii.borderRadiusFull, + boxShadow: AppShadows.md, + border: Border.all(color: AppColors.border), + ), + child: Row( + children: [ + const Icon(Icons.explore, color: AppColors.primary, size: 20), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + exploration.currentHeading != null + ? 'Facing ${exploration.currentHeading!.round()}°' + : 'Accessible Navigator', + style: AppTypography.labelLarge.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ), + ), + const SizedBox(width: AppSpacing.sm), + Material( + color: AppColors.surface, + elevation: 3, + shape: const CircleBorder(), + child: IconButton( + icon: const Icon(Icons.close, color: AppColors.error, size: 22), + onPressed: () => exploration.stopExploration(), + tooltip: 'Exit Navigator', + ), + ), + ], + ), + ), + + // Bottom Floating Exploration Panel + Positioned( + left: AppSpacing.md, + right: AppSpacing.md, + bottom: AppSpacing.md, + child: Container( + constraints: const BoxConstraints(maxHeight: 240), + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: AppRadii.borderRadiusLg, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.18), + blurRadius: 16, + offset: const Offset(0, 6), + ), + ], + border: Border.all(color: AppColors.border, width: 1.5), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Quick Action Row: Where Am I & Hazard + Row( + children: [ + Expanded( + child: ElevatedButton( + 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: 20), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + loc, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 13, + ), + ), + ), + ], + ), + duration: const Duration(seconds: 4), + ), + ); + } + }, + style: ElevatedButton.styleFrom( + minimumSize: const Size.fromHeight(40), + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 8), + shape: RoundedRectangleBorder( + borderRadius: AppRadii.borderRadiusMd, + ), + elevation: 2, + ), + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.my_location, size: 16), + SizedBox(width: 4), + Flexible( + child: Text( + 'Where Am I?', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12), + ), + ), + ], + ), + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: ElevatedButton( + onPressed: () => _showReportHazardSheet(context, exploration), + style: ElevatedButton.styleFrom( + minimumSize: const Size.fromHeight(40), + backgroundColor: const Color(0xFFD97706), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 8), + shape: RoundedRectangleBorder( + borderRadius: AppRadii.borderRadiusMd, + ), + elevation: 2, + ), + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.warning_amber_rounded, size: 16), + SizedBox(width: 4), + Flexible( + child: Text( + 'Report Hazard', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12), + ), + ), + ], + ), + ), + ), + ], + ), + const SizedBox(height: AppSpacing.sm), + + // Category Slider Title + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Text( + 'Explore Nearby Places', + style: AppTypography.titleMedium.copyWith( + fontWeight: FontWeight.bold, + ), + overflow: TextOverflow.ellipsis, + ), + ), + Text( + 'Tap to view', + style: AppTypography.bodySmall.copyWith( + color: AppColors.textSecondary, + ), + ), + ], + ), + const SizedBox(height: AppSpacing.xs), + + // Horizontal Category Cards + SizedBox( + height: 88, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: categories.length, + separatorBuilder: (_, __) => const SizedBox(width: AppSpacing.sm), + itemBuilder: (context, index) { + final cat = categories[index]; + final matchingCount = state.places.where(cat.filter).length; + + return InkWell( + onTap: () => _showCategoryPlacesSheet( + context, + cat, + state, + exploration, + ), + borderRadius: AppRadii.borderRadiusMd, + child: Container( + width: 104, + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6), + decoration: BoxDecoration( + color: AppColors.surfaceVariant.withValues(alpha: 0.35), + borderRadius: AppRadii.borderRadiusMd, + border: Border.all( + color: cat.color.withValues(alpha: 0.5), + width: 1.5, + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + CircleAvatar( + radius: 13, + backgroundColor: cat.color.withValues(alpha: 0.15), + foregroundColor: cat.color, + child: Icon(cat.icon, size: 15), + ), + const SizedBox(height: 3), + Text( + cat.label, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 9, + fontWeight: FontWeight.bold, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 1), + Text( + '$matchingCount nearby', + style: const TextStyle( + fontSize: 9, + color: AppColors.textSecondary, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ); + }, + ), + ), + ], + ), + ), + ), + ], + ); + } + + // ========================================== + // 2. GOOGLE MAPS-STYLE TURN-BY-TURN HUD + // ========================================== + Widget _buildGoogleMapsNavigationHUD( + BuildContext context, + ExplorationService exploration, + AppState state, + ) { + final dest = exploration.activeDestination!; + final totalDistance = (exploration.distanceToDestination ?? 0).round(); + final walkTime = exploration.estimatedWalkingMinutes; + final nearbyHazard = exploration.nearbyHazard; + final currentStep = exploration.currentStep; + final stepDist = (exploration.currentStepRemainingMeters).round(); + final bearing = exploration.bearingToNextWaypoint; + final heading = exploration.currentHeading; + final speedMs = exploration.currentSpeedMs; + final speedKmh = speedMs != null ? (speedMs * 3.6) : null; + + // Relative angle: how many degrees to turn to face the next waypoint + double? relativeAngle; + String turnAdvice = 'On track'; + if (bearing != null && heading != null) { + relativeAngle = ((bearing - heading) % 360 + 360) % 360; + final diff = relativeAngle > 180 ? relativeAngle - 360 : relativeAngle; + if (diff.abs() <= 12) { + turnAdvice = 'Facing route'; + } else if (diff > 0) { + turnAdvice = 'Turn ${diff.round()}° right'; + } else { + turnAdvice = 'Turn ${(-diff).round()}° left'; + } + } + + final distDisplay = totalDistance >= 1000 + ? '${(totalDistance / 1000).toStringAsFixed(1)} km' + : '$totalDistance m'; + + // Walk time label + final walkTimeLabel = walkTime == 0 + ? 'Arrived!' + : walkTime < 60 + ? '$walkTime min' + : '${walkTime ~/ 60}h ${walkTime % 60}m'; + + + + 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: [ + // ── Gyro Compass Row ──────────────────────────── + Row( + children: [ + // Live compass arrow disk + _GyroCompass(relativeAngle: relativeAngle, heading: heading), + const SizedBox(width: AppSpacing.sm), + // Step countdown capsule + Expanded( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md, vertical: 6), + decoration: BoxDecoration( + color: Colors.black87, + borderRadius: AppRadii.borderRadiusFull, + boxShadow: AppShadows.sm, + ), + child: Row( + children: [ + const Icon(Icons.near_me, color: Colors.white, size: 14), + const SizedBox(width: 6), + Expanded( + child: Text( + currentStep != null + ? '$turnAdvice • in ${stepDist}m' + : '$turnAdvice • $distDisplay', + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + overflow: TextOverflow.ellipsis, + ), + ), + if (speedKmh != null) ...[ + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.white24, + borderRadius: AppRadii.borderRadiusFull, + ), + child: Text( + '${speedKmh.toStringAsFixed(1)} km/h', + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ], + ), + ), + ), + ], + ), + const SizedBox(height: AppSpacing.xs), + // ── Turn Banner ───────────────────────────────── + Container( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md, vertical: AppSpacing.sm + 2), + decoration: BoxDecoration( + color: const Color(0xFF0F766E), + borderRadius: AppRadii.borderRadiusLg, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.35), + blurRadius: 14, + offset: const Offset(0, 5), + ), + ], + ), + child: Row( + children: [ + CircleAvatar( + + radius: 22, + backgroundColor: Colors.white, + foregroundColor: const Color(0xFF0F766E), + child: Icon(currentStep?.icon ?? Icons.navigation, size: 26), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + child: Text( + currentStep != null + ? (stepDist <= 10 ? 'Approaching Turn' : 'In $stepDist m') + : 'Accessible Navigation', + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (exploration.navigationSteps.isNotEmpty) + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.white24, + borderRadius: BorderRadius.circular(10), + ), + child: Text( + '${exploration.currentStepIndex + 1}/${exploration.navigationSteps.length}', + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(width: 8), + GestureDetector( + onTap: () { + exploration.clearDestination(); + context.read().clearSelectedPlace(); + }, + child: Container( + padding: const EdgeInsets.all(3), + decoration: const BoxDecoration( + color: Colors.white24, + shape: BoxShape.circle, + ), + child: const Icon(Icons.close, size: 14, color: Colors.white), + ), + ), + ], + ), + const SizedBox(height: 2), + Text( + exploration.navigationInstruction, + style: const TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (exploration.nextTurnHint.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + exploration.nextTurnHint, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.85), + fontSize: 11, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + ], + ), + ), + 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( + constraints: const BoxConstraints(maxHeight: 260), + 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: SingleChildScrollView( + physics: const NeverScrollableScrollPhysics(), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Text( + dest.name, + style: AppTypography.titleLarge.copyWith(fontWeight: FontWeight.bold), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: AppSpacing.xs), + 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: 4), + IntrinsicHeight( + child: Row( + children: [ + Expanded( + child: _NavMetric( + label: 'Distance', + value: distDisplay, + icon: Icons.straighten, + ), + ), + const VerticalDivider(width: 1), + Expanded( + child: _NavMetric( + label: '${exploration.currentRoutingProfile.displayName} Time', + value: walkTimeLabel, + icon: exploration.currentRoutingProfile.icon, + isLive: speedKmh != null, + ), + ), + const VerticalDivider(width: 1), + Expanded( + child: _NavMetric( + label: speedKmh != null ? 'Your Speed' : 'Avg Speed', + value: speedKmh != null + ? '${speedKmh.toStringAsFixed(1)} km/h' + : exploration.currentRoutingProfile == OsrmProfile.car + ? '~30 km/h' + : exploration.currentRoutingProfile == OsrmProfile.bike + ? '~15 km/h' + : '~4.5 km/h', + icon: Icons.speed, + isLive: speedKmh != null, + ), + ), + ], + ), + ), + const SizedBox(height: 6), + + // ── Transport Mode Toggle (Walk, Bike, Car) ── + Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.grey.shade300, width: 1), + ), + child: Row( + children: OsrmProfile.values.map((profile) { + final isSelected = exploration.currentRoutingProfile == profile; + return Expanded( + child: GestureDetector( + onTap: () => exploration.setRoutingProfile(profile), + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + padding: const EdgeInsets.symmetric(vertical: 5), + decoration: BoxDecoration( + color: isSelected ? const Color(0xFF0F766E) : Colors.transparent, + borderRadius: BorderRadius.circular(7), + boxShadow: isSelected + ? [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.15), + blurRadius: 4, + offset: const Offset(0, 1), + ), + ] + : null, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + profile.icon, + size: 15, + color: isSelected ? Colors.white : Colors.black87, + ), + const SizedBox(width: 4), + Text( + profile.displayName, + style: TextStyle( + fontSize: 11, + fontWeight: isSelected ? FontWeight.bold : FontWeight.w600, + color: isSelected ? Colors.white : Colors.black87, + ), + ), + ], + ), + ), + ), + ); + }).toList(), + ), + ), + const SizedBox(height: 6), + + // Action Row: Step Ahead, Report Hazard, and End + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: () => exploration.stepAheadSimulated(30.0), + icon: Icon(exploration.currentRoutingProfile.icon, size: 14), + label: const Flexible( + child: Text( + 'Step +30m', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold), + ), + ), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF0F766E), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 7), + shape: RoundedRectangleBorder( + borderRadius: AppRadii.borderRadiusMd, + ), + ), + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: ElevatedButton.icon( + onPressed: () => _showReportHazardSheet(context, exploration), + icon: const Icon(Icons.warning_amber_rounded, size: 14), + label: const Flexible( + child: Text( + 'Hazard', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold), + ), + ), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFD97706), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 7), + shape: RoundedRectangleBorder( + borderRadius: AppRadii.borderRadiusMd, + ), + ), + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: OutlinedButton( + onPressed: () { + exploration.clearDestination(); + context.read().clearSelectedPlace(); + }, + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.error, + side: const BorderSide(color: AppColors.error, width: 1.5), + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 7), + shape: RoundedRectangleBorder( + borderRadius: AppRadii.borderRadiusMd, + ), + ), + child: const Text( + 'End', + maxLines: 1, + style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold), + ), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ], + ); + } + + /// 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, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + '${hazard.description} • ${hazard.ageLabel}', + style: const TextStyle( + fontSize: 11, + color: Color(0xFF78350F), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const SizedBox(width: 6), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: hazard.category.color, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + visualDensity: VisualDensity.compact, + ), + onPressed: () => _showHazardVoteSheet(context, exploration, hazard), + child: const Text('Vote', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold)), + ), + ], + ), + const SizedBox(height: 4), + // Mini vote summary bar + Row( + children: [ + Text( + '${hazard.blockedVotes} blocked', + style: const TextStyle(fontSize: 10, color: Color(0xFF92400E), fontWeight: FontWeight.w600), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: hazard.fixedVoteFraction, + minHeight: 5, + backgroundColor: const Color(0xFFDC2626).withValues(alpha: 0.25), + valueColor: const AlwaysStoppedAnimation(Color(0xFF059669)), + ), + ), + ), + ), + Text( + '${hazard.fixedVotes} fixed', + style: const TextStyle(fontSize: 10, color: Color(0xFF065F46), fontWeight: FontWeight.w600), + ), + ], + ), + ], + ), + ); + } + + /// 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), + ], + ), + ), + ), + ), + ); + } + + // ========================================== + // HAZARD COMMUNITY VOTE SHEET + // ========================================== + void _showHazardVoteSheet( + BuildContext context, + ExplorationService exploration, + HazardReport hazard, + ) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (ctx) => StatefulBuilder( + builder: (ctx, setModalState) { + final blockedWinning = hazard.communityVerdictIsBlocked; + final total = hazard.totalVotes; + final fixedPct = total == 0 ? 0 : ((hazard.fixedVotes / total) * 100).round(); + final blockedPct = 100 - fixedPct; + + return SafeArea( + child: Container( + padding: const EdgeInsets.all(AppSpacing.lg), + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Handle + Center( + child: Container( + width: 40, height: 4, + margin: const EdgeInsets.only(bottom: AppSpacing.md), + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + + // Header + Row( + children: [ + CircleAvatar( + radius: 22, + backgroundColor: hazard.category.color.withValues(alpha: 0.15), + foregroundColor: hazard.category.color, + child: Icon(hazard.category.icon, size: 24), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + hazard.category.label, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + Text( + hazard.description, + style: TextStyle(fontSize: 13, color: Colors.grey.shade600), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + const SizedBox(height: AppSpacing.lg), + + // Community verdict banner + Container( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md, vertical: AppSpacing.sm), + decoration: BoxDecoration( + color: blockedWinning + ? const Color(0xFFFEF3C7) + : const Color(0xFFD1FAE5), + borderRadius: AppRadii.borderRadiusMd, + border: Border.all( + color: blockedWinning + ? const Color(0xFFF59E0B) + : const Color(0xFF059669), + ), + ), + child: Row( + children: [ + Icon( + blockedWinning ? Icons.warning_amber_rounded : Icons.check_circle, + color: blockedWinning ? const Color(0xFFD97706) : const Color(0xFF059669), + size: 22, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + blockedWinning + ? 'Community says: Still Blocked ($total votes)' + : 'Community says: Fixed / Cleared ($total votes)', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + color: blockedWinning + ? const Color(0xFF92400E) + : const Color(0xFF065F46), + ), + ), + ), + ], + ), + ), + const SizedBox(height: AppSpacing.md), + + // Vote bar + Row( + children: [ + const Text('🚧', style: TextStyle(fontSize: 16)), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: LinearProgressIndicator( + value: hazard.fixedVoteFraction, + minHeight: 12, + backgroundColor: const Color(0xFFDC2626).withValues(alpha: 0.2), + valueColor: const AlwaysStoppedAnimation(Color(0xFF059669)), + ), + ), + const SizedBox(height: 4), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '$blockedPct% Still Blocked (${hazard.blockedVotes})', + style: const TextStyle(fontSize: 11, color: Color(0xFFDC2626), fontWeight: FontWeight.bold), + ), + Text( + '$fixedPct% Fixed (${hazard.fixedVotes})', + style: const TextStyle(fontSize: 11, color: Color(0xFF059669), fontWeight: FontWeight.bold), + ), + ], + ), + ], + ), + ), + ), + const Text('✅', style: TextStyle(fontSize: 16)), + ], + ), + const SizedBox(height: AppSpacing.md), + Text( + 'Needs ${(3 - total).clamp(0, 3)} more votes to auto-resolve', + style: TextStyle(fontSize: 12, color: Colors.grey.shade500), + textAlign: TextAlign.center, + ), + const SizedBox(height: AppSpacing.lg), + + // Vote buttons + if (!hazard.isResolved) ...[ + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: () { + exploration.voteHazardBlocked(hazard.id); + setModalState(() {}); + }, + icon: const Icon(Icons.report_gmailerrorred, size: 18), + label: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Still Blocked', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), + Text('+5 pts', style: TextStyle(fontSize: 10, color: Colors.orange.shade100)), + ], + ), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFDC2626), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: AppRadii.borderRadiusMd), + ), + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: ElevatedButton.icon( + onPressed: () { + exploration.voteHazardFixed(hazard.id); + setModalState(() {}); + if (hazard.isResolved) { + Navigator.of(ctx).pop(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + behavior: SnackBarBehavior.floating, + backgroundColor: const Color(0xFF059669), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + content: const Row( + children: [ + Icon(Icons.stars, color: Colors.amber), + SizedBox(width: 8), + Expanded(child: Text('Hazard resolved by community! +25 pts bonus earned 🎉', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold))), + ], + ), + duration: const Duration(seconds: 4), + ), + ); + } + }, + icon: const Icon(Icons.check_circle, size: 18), + label: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('It\'s Fixed!', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), + Text('+10 pts', style: TextStyle(fontSize: 10, color: Colors.green.shade100)), + ], + ), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF059669), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: AppRadii.borderRadiusMd), + ), + ), + ), + ], + ), + const SizedBox(height: AppSpacing.sm), + Text( + 'Reported ${hazard.ageLabel} • Your vote helps the community', + style: TextStyle(fontSize: 11, color: Colors.grey.shade500), + textAlign: TextAlign.center, + ), + ] else ...[ + Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: const Color(0xFFD1FAE5), + borderRadius: AppRadii.borderRadiusMd, + ), + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.check_circle, color: Color(0xFF059669)), + SizedBox(width: 8), + Text( + 'Hazard resolved — path is clear! ✅', + style: TextStyle(color: Color(0xFF065F46), fontWeight: FontWeight.bold), + ), + ], + ), + ), + ], + ], + ), + ), + ); + }, + ), + ); + } + + // ========================================== + // 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, + this.isLive = false, + }); + + final String label; + final String value; + final IconData icon; + final bool isLive; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Stack( + clipBehavior: Clip.none, + children: [ + Icon(icon, color: AppColors.primary, size: 22), + if (isLive) + Positioned( + top: -2, + right: -4, + child: Container( + width: 7, + height: 7, + decoration: const BoxDecoration( + color: Color(0xFF22C55E), + shape: BoxShape.circle, + ), + ), + ), + ], + ), + const SizedBox(height: 2), + Text( + value, + style: AppTypography.titleLarge.copyWith(fontWeight: FontWeight.bold), + ), + Text( + label, + style: AppTypography.bodySmall.copyWith(color: AppColors.textSecondary), + ), + ], + ); + } +} + +/// Gyroscope compass widget — rotates to point toward next waypoint +/// relative to the device's current heading. +class _GyroCompass extends StatelessWidget { + const _GyroCompass({ + required this.relativeAngle, + required this.heading, + }); + + /// Angle (0–360°) between device heading and next waypoint bearing. + /// Null when compass/GPS data unavailable. + final double? relativeAngle; + final double? heading; + + @override + Widget build(BuildContext context) { + // Convert to radians for Transform.rotate + final angle = relativeAngle != null + ? (relativeAngle! * 3.14159265358979 / 180.0) + : 0.0; + final hasData = relativeAngle != null && heading != null; + + return Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: Colors.black87, + shape: BoxShape.circle, + boxShadow: AppShadows.md, + border: Border.all( + color: hasData + ? const Color(0xFF0F766E) + : Colors.white24, + width: 2, + ), + ), + child: Stack( + alignment: Alignment.center, + children: [ + // N/S/E/W tick marks + ...List.generate(4, (i) { + final tickAngle = i * 3.14159265358979 / 2; + return Transform.rotate( + angle: tickAngle, + child: Align( + alignment: Alignment.topCenter, + child: Container( + width: 2, + height: 5, + margin: const EdgeInsets.only(top: 4), + color: Colors.white24, + ), + ), + ); + }), + // North label + const Align( + alignment: Alignment.topCenter, + child: Padding( + padding: EdgeInsets.only(top: 4), + child: Text( + 'N', + style: TextStyle( + color: Colors.white54, + fontSize: 7, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + // Rotating direction arrow + AnimatedRotation( + turns: angle / (2 * 3.14159265358979), + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Arrow head + CustomPaint( + size: const Size(12, 16), + painter: _ArrowPainter( + color: hasData + ? const Color(0xFF0F766E) + : Colors.white54, + ), + ), + // Arrow tail + Container( + width: 2, + height: 6, + color: hasData + ? const Color(0xFF0F766E).withValues(alpha: 0.7) + : Colors.white30, + ), + ], + ), + ), + // Center dot + Container( + width: 6, + height: 6, + decoration: BoxDecoration( + color: hasData ? const Color(0xFF0F766E) : Colors.white38, + shape: BoxShape.circle, + ), + ), + // Degree badge + if (hasData && relativeAngle != null) + Positioned( + bottom: 3, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 0.5), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.75), + borderRadius: BorderRadius.circular(4), + ), + child: Builder(builder: (_) { + final diff = relativeAngle! > 180 ? relativeAngle! - 360 : relativeAngle!; + final degText = diff.abs() <= 5 + ? '0°' + : '${diff > 0 ? 'R' : 'L'}${diff.abs().round()}°'; + return Text( + degText, + style: const TextStyle( + color: Colors.white, + fontSize: 7.5, + fontWeight: FontWeight.bold, + ), + ); + }), + ), + ), + // No-data indicator + if (!hasData) + const Positioned( + bottom: 4, + child: Text( + '?', + style: TextStyle( + color: Colors.white38, + fontSize: 8, + ), + ), + ), + ], + ), + ); + } +} + +/// Custom arrow-head painter for the compass needle +class _ArrowPainter extends CustomPainter { + const _ArrowPainter({required this.color}); + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..style = PaintingStyle.fill; + final path = Path() + ..moveTo(size.width / 2, 0) + ..lineTo(size.width, size.height) + ..lineTo(size.width / 2, size.height * 0.7) + ..lineTo(0, size.height) + ..close(); + canvas.drawPath(path, paint); + } + + @override + bool shouldRepaint(_ArrowPainter old) => old.color != color; +} 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 f144ea2..b8732e2 100644 --- a/lib/features/map/presentation/map_screen.dart +++ b/lib/features/map/presentation/map_screen.dart @@ -4,14 +4,21 @@ import 'package:access_map/app/app_state.dart'; import 'package:access_map/core/theme/app_theme.dart'; import 'package:access_map/features/contribute/presentation/add_location_screen.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}); @@ -23,11 +30,142 @@ 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; + String? _lastFittedDestId; + + @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; + + if (exploration.activeDestination != null && exploration.activeDestination!.id != _lastFittedDestId) { + _lastFittedDestId = exploration.activeDestination!.id; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final userLoc = LatLng(exploration.currentEffectiveLat, exploration.currentEffectiveLng); + final destLoc = LatLng(exploration.activeDestination!.latitude, exploration.activeDestination!.longitude); + try { + _mapController.fitCamera( + CameraFit.coordinates( + coordinates: [userLoc, destLoc], + padding: const EdgeInsets.fromLTRB(40, 110, 40, 250), + ), + ); + } catch (_) {} + }); + } else if (exploration.activeDestination == null) { + _lastFittedDestId = null; + } + return SafeArea( child: Stack( children: [ @@ -35,37 +173,99 @@ 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, + currentHeading: exploration.currentHeading, + 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: 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( right: AppSpacing.lg, - bottom: state.selectedPlace == null ? AppSpacing.lg : 190, + bottom: isNavigating ? 190 : (state.selectedPlace == null ? AppSpacing.lg : 190), child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -84,21 +284,53 @@ class _MapScreenState extends State { const SizedBox(height: AppSpacing.md), 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), + 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( + 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 (!exploration.isActive && !isNavigating) + Positioned( + left: AppSpacing.lg, + bottom: (state.selectedPlace == null ? AppSpacing.lg : 190) + 64, + child: FloatingActionButton.extended( + heroTag: 'discover', + backgroundColor: const Color(0xFF0F766E), + foregroundColor: Colors.white, + onPressed: () async { + final error = await context.read().toggleExplorationMode(); + if (error != null && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(error)), + ); + } + }, + icon: const Icon(Icons.explore), + label: const Text('Accessible Explorer', style: TextStyle(fontWeight: FontWeight.bold)), + ), + ), if (state.errorMessage != null) EmptyState( title: "Couldn't load nearby places", @@ -115,17 +347,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; } @@ -139,59 +377,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(() {}); + }, + ), + ); + }), ], ), - ], + ), ), ); }, @@ -205,49 +589,245 @@ class _AccessibleMap extends StatelessWidget { const _AccessibleMap({ required this.places, required this.selectedPlace, + this.activeDestination, + this.routePoints = const [], + this.hazards = const [], + this.userLocation, + this.currentHeading, + 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 double? currentHeading; + 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', - userAgentPackageName: 'com.example.access_map', + urlTemplate: mapStyle.urlTemplate, + userAgentPackageName: 'com.accessmap.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), - decoration: BoxDecoration( - color: selected ? AppColors.secondary : AppColors.primary, - shape: BoxShape.circle, - border: Border.all(color: AppColors.surface, width: 3), - boxShadow: AppShadows.md, + markers: [ + // User location marker with live compass rotation and directional beam + if (userLocation != null) + Marker( + point: userLocation!, + width: 56, + height: 56, + child: Transform.rotate( + angle: ((currentHeading ?? 0) * (math.pi / 180.0)), + child: Stack( + alignment: Alignment.center, + children: [ + // Directional field-of-view beam + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + Colors.blue.shade600.withValues(alpha: 0.35), + Colors.blue.shade600.withValues(alpha: 0.0), + ], + ), + ), + ), + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: const Color(0xFF1D4ED8), + border: Border.all(color: Colors.white, width: 3), + boxShadow: const [ + BoxShadow(color: Colors.black26, blurRadius: 6, offset: Offset(0, 2)), + ], + ), + child: const Center( + child: Icon(Icons.navigation, color: Colors.white, size: 18), + ), + ), + ], ), - child: Icon( - place.category.icon, - color: AppColors.textOnPrimary, - size: selected ? 28 : 23, + ), + ), + + // Route turn waypoints + if (routePoints.length > 2) + ...routePoints.sublist(1, routePoints.length - 1).map( + (pt) => Marker( + point: pt, + width: 14, + height: 14, + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white, + border: Border.all(color: const Color(0xFF0F766E), width: 3), + boxShadow: const [ + BoxShadow(color: Colors.black26, blurRadius: 4), + ], + ), ), ), ), - ); - }).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, + ), + ), + ), + ); + }), + ], ), ], ); @@ -268,79 +848,191 @@ class PlacePreviewSheet extends StatelessWidget { @override Widget build(BuildContext context) { - final relevant = place.availableFeatures.take(3).toList(); - return Card( - elevation: 0, - child: InkWell( + final relevant = place.availableFeatures.take(2).toList(); + return Container( + decoration: BoxDecoration( + color: AppColors.surface, borderRadius: AppRadii.borderRadiusMd, - onTap: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => PlaceDetailsScreen(placeId: place.id), + 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: Padding( - padding: const EdgeInsets.all(AppSpacing.lg), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - Expanded( - child: Text( - place.name, - style: AppTypography.headlineSmall, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - if (place.friendlyScore == 0 && place.totalReviews == 0) - Container( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.sm, - vertical: AppSpacing.xs, - ), - decoration: BoxDecoration( - color: AppColors.primarySurface, - borderRadius: AppRadii.borderRadiusFull, - ), + ], + ), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: AppRadii.borderRadiusMd, + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => PlaceDetailsScreen(placeId: place.id), + ), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 10, 14, 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( child: Text( - 'New', - style: AppTypography.labelMedium - .copyWith(color: AppColors.primary), + place.name, + style: AppTypography.headlineSmall.copyWith(fontSize: 18), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - ) - else - Text( - '${place.friendlyScore.toStringAsFixed(1)} Friendly', - style: AppTypography.titleMedium.copyWith( - color: AppColors.scoreColor(place.friendlyScore), + ), + const SizedBox(width: AppSpacing.sm), + if (place.friendlyScore == 0 && place.totalReviews == 0) + Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: AppSpacing.xs, + ), + decoration: BoxDecoration( + color: AppColors.primarySurface, + borderRadius: AppRadii.borderRadiusFull, + ), + child: Text( + 'New', + style: AppTypography.labelMedium + .copyWith(color: AppColors.primary), + ), + ) + else + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + 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: 11, + ), + ), ), + const SizedBox(width: AppSpacing.xs), + IconButton( + icon: const Icon(Icons.close, size: 20), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + onPressed: () => context.read().clearSelectedPlace(), ), + ], + ), + const SizedBox(height: 2), + Text( + '${place.category.displayName}' + '${userLatitude != null && userLongitude != null ? ' • ${_formatDistance(place.distanceKmFrom(userLatitude!, userLongitude!))}' : ''}' + ' • ${place.communityConfirmations} confirmations', + style: AppTypography.bodySmall.copyWith(fontSize: 11), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (relevant.isNotEmpty) ...[ + const SizedBox(height: 6), + Wrap( + spacing: 6, + runSpacing: 4, + children: relevant + .take(2) + .map( + (feature) => Chip( + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + padding: const EdgeInsets.symmetric(horizontal: 4), + avatar: Icon(feature.icon, size: 14), + label: Text(feature.name, style: const TextStyle(fontSize: 11)), + ), + ) + .toList(), + ), ], - ), - const SizedBox(height: AppSpacing.xs), - Text( - '${place.category.displayName}' - '${userLatitude != null && userLongitude != null ? ' - ${_formatDistance(place.distanceKmFrom(userLatitude!, userLongitude!))}' : ''}' - ' - ${place.communityConfirmations} confirmations', - style: AppTypography.bodySmall, - ), - const SizedBox(height: AppSpacing.md), - Wrap( - spacing: AppSpacing.sm, - runSpacing: AppSpacing.sm, - children: relevant - .map( - (feature) => Chip( - visualDensity: VisualDensity.compact, - avatar: Icon(feature.icon, size: 16), - label: Text(feature.name), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + flex: 3, + child: SizedBox( + height: 38, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF0F766E), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 8), + shape: RoundedRectangleBorder( + borderRadius: AppRadii.borderRadiusMd, + ), + ), + onPressed: () async { + final appState = context.read(); + final error = await appState.explorationService.startExploration(); + if (error != null) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(error)), + ); + return; + } + appState.explorationService.setDestination(place); + appState.clearSelectedPlace(); + }, + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.directions_walk, size: 18), + SizedBox(width: 6), + Flexible( + child: Text( + 'Directions', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13), + ), + ), + ], + ), + ), ), - ) - .toList(), - ), - ], + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + flex: 2, + child: SizedBox( + height: 38, + child: OutlinedButton( + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 8), + shape: RoundedRectangleBorder( + borderRadius: AppRadii.borderRadiusMd, + ), + ), + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => PlaceDetailsScreen(placeId: place.id), + ), + ), + child: const FittedBox( + fit: BoxFit.scaleDown, + child: Text('Details', style: TextStyle(fontSize: 13)), + ), + ), + ), + ), + ], + ), + ], + ), ), ), ), @@ -363,14 +1055,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), @@ -404,6 +1095,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..0775fb1 --- /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: 'Spoken Audio Guidance', + subtitle: 'Audio landmark announcements ("Where Am I?") and turn cues 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 0f4f7be..e809919 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/models/user_profile.dart'; @@ -37,6 +38,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), @@ -56,18 +59,48 @@ class PlaceDetailsScreen extends StatelessWidget { if (place.source == PlaceSource.community) _CommunityAttributionBanner(place: place), _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: () async { + final appState = context.read(); + final error = await appState.explorationService.startExploration(); + if (error != null) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(error)), + ); + return; + } + appState.explorationService.setDestination(place); + if (!context.mounted) return; + 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'), if (place.friendlyScore == 0 && place.totalReviews == 0) @@ -190,7 +223,7 @@ class PlaceDetailsScreen extends StatelessWidget { shrinkWrap: true, padding: const EdgeInsets.all(AppSpacing.lg), children: [ - const Text('Report accessibility information', + Text('Report accessibility information', style: AppTypography.headlineSmall), const SizedBox(height: AppSpacing.sm), Text( @@ -241,7 +274,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( @@ -392,14 +425,14 @@ class _ProvisionalScoresPanel extends StatelessWidget { ], ), const SizedBox(height: AppSpacing.xs), - const Text( + Text( 'Not enough community ratings yet. This place was recently added; ' 'scores appear as the community reviews and confirms it.', style: AppTypography.bodyMedium, ), if (policy.showWheelchairScore(profile)) ...[ const SizedBox(height: AppSpacing.md), - const Text( + Text( 'Wheelchair-Friendly - awaiting community ratings', style: AppTypography.bodyMedium, ), @@ -525,30 +558,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), + ), ), ), - ), - ], + ], + ), ), ); } @@ -557,8 +594,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 845a73e..596f015 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'), ), ), ], @@ -81,7 +124,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 @@ -89,17 +308,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( @@ -111,6 +334,62 @@ 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) async { + if (value != null) { + final appState = context.read(); + await appState.setTTSLanguage(value); + if (value == 'hi-IN') { + appState.ttsService.speak('हिंदी आवाज़ चुनी गई है।', force: true); + } else { + appState.ttsService.speak('English voice selected.', force: true); + } + } + }, + ), + 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( @@ -133,8 +412,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, ), ), @@ -142,17 +421,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..89f6781 --- /dev/null +++ b/lib/shared/models/hazard_report.dart @@ -0,0 +1,161 @@ +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; + + /// Votes from community that hazard is STILL blocked/present. + int blockedVotes; + + /// Votes from community that hazard is FIXED/resolved. + int fixedVotes; + + bool isResolved; + + /// Whether the app has awarded points for this hazard being resolved. + bool _resolvedPointsAwarded = false; + bool get resolvedPointsAwarded => _resolvedPointsAwarded; + + // Legacy aliases for backwards-compat + int get confirmationCount => blockedVotes; + int get disputeCount => fixedVotes; + + HazardReport({ + required this.id, + required this.category, + required this.description, + required this.latitude, + required this.longitude, + DateTime? reportedAt, + DateTime? lastConfirmedAt, + this.blockedVotes = 1, + this.fixedVotes = 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'; + } + + int get totalVotes => blockedVotes + fixedVotes; + + /// Winning verdict: true = still blocked, false = fixed + bool get communityVerdictIsBlocked => + fixedVotes == 0 || blockedVotes >= fixedVotes; + + /// Progress fraction of "fixed" votes out of total + double get fixedVoteFraction => + totalVotes == 0 ? 0 : fixedVotes / totalVotes; + + /// Auto-resolves when fixed votes are strictly more than blocked AND + /// total votes reach at least 3 (so 1 vote can't instantly resolve). + bool get shouldAutoResolve => + !isResolved && fixedVotes > blockedVotes && totalVotes >= 3; + + void voteBlocked() { + blockedVotes += 1; + lastConfirmedAt = DateTime.now(); + } + + void voteFixed() { + fixedVotes += 1; + lastConfirmedAt = DateTime.now(); + if (shouldAutoResolve) { + isResolved = true; + } + } + + /// Manually mark as resolved and prevent duplicate point awards. + bool resolve() { + if (_resolvedPointsAwarded) return false; + isResolved = true; + _resolvedPointsAwarded = true; + return true; // caller should award points + } + + // Legacy compat methods + void confirmStillPresent() => voteBlocked(); + void markFixed() => isResolved = true; + void dispute() => fixedVotes += 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 d8c2b6d..c654fcf 100644 --- a/lib/shared/models/place.dart +++ b/lib/shared/models/place.dart @@ -156,6 +156,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 a7b8c8a..078a8d8 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, @@ -66,7 +134,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; @@ -79,6 +147,8 @@ class UserProfile { final int locationCount; final List recentActivity; final bool onboardingComplete; + final MedicalInfo? medicalInfo; + final List emergencyContacts; const UserProfile({ required this.id, @@ -92,6 +162,8 @@ class UserProfile { this.locationCount = 0, this.recentActivity = const [], this.onboardingComplete = false, + this.medicalInfo, + this.emergencyContacts = const [], }); /// Check if the user's profile warrants showing wheelchair-specific score. @@ -110,6 +182,8 @@ class UserProfile { int? locationCount, List? recentActivity, bool? onboardingComplete, + MedicalInfo? medicalInfo, + List? emergencyContacts, }) { return UserProfile( id: id, @@ -123,44 +197,71 @@ class UserProfile { locationCount: locationCount ?? this.locationCount, 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, locationCount: 1, + 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 5fc41c5..d6ae704 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 f29183f..402287c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -37,10 +37,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" checked_yaml: dependency: transitive description: @@ -65,6 +65,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: @@ -97,6 +105,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: @@ -113,6 +137,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: @@ -134,6 +166,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_launcher_icons: dependency: "direct dev" description: @@ -163,6 +203,14 @@ 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 @@ -256,8 +304,24 @@ packages: url: "https://pub.dev" source: hosted version: "14.8.1" - http: + 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: "direct main" description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" @@ -288,6 +352,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: @@ -364,26 +452,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: @@ -400,6 +488,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: @@ -408,6 +512,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: @@ -480,6 +608,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: @@ -609,10 +769,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: @@ -702,13 +862,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: @@ -725,6 +901,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: @@ -750,5 +942,5 @@ packages: source: hosted version: "3.1.4" sdks: - dart: ">=3.9.2 <4.0.0" - flutter: ">=3.35.0" + dart: ">=3.11.0 <4.0.0" + flutter: ">=3.41.0" diff --git a/pubspec.yaml b/pubspec.yaml index 9c8cedf..cf1324f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,11 +15,18 @@ 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 geocoding: ^3.0.0 intl: ^0.20.2 + flutter_compass: ^0.8.1 + vibration: ^3.2.1 + vector_math: ^2.4.2 + google_fonts: ^8.2.1 + qr_flutter: ^4.1.0 + http: ^1.2.2 dev_dependencies: flutter_test: diff --git a/test/acceptance_flow_test.dart b/test/acceptance_flow_test.dart index 0372b96..e62b3d1 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'; @@ -10,47 +10,38 @@ import 'package:shared_preferences/shared_preferences.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); SharedPreferences.setMockInitialValues({}); 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.pump(); - await tester.ensureVisible(find.text('Open Map')); + await tester.tap(needFinder); 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.tap(ctaFinder); await tester.pumpAndSettle(); - await tester.pump(const Duration(milliseconds: 400)); + 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). @@ -64,60 +55,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 { @@ -126,15 +91,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(); @@ -150,10 +118,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(); @@ -165,13 +133,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); @@ -182,7 +151,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); @@ -199,7 +172,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 = @@ -212,25 +185,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, ); }); @@ -238,6 +217,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 3372933..9423ccf 100644 --- a/test/models_test.dart +++ b/test/models_test.dart @@ -15,39 +15,79 @@ import 'package:shared_preferences/shared_preferences.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); + 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);