diff --git a/.claude/settings.local.json b/.claude/settings.local.json index da0e963..ad40376 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -4,7 +4,24 @@ "Bash(flutter analyze lib)", "Read(//Users/dz0ny/meshcore-sar/MeshCore/**)", "Bash(flutter analyze lib/providers/connection_provider.dart)", - "Bash(flutter analyze lib/providers/connection_provider.dart --fatal-infos)" + "Bash(flutter analyze lib/providers/connection_provider.dart --fatal-infos)", + "Bash(flutter analyze:*)", + "Bash(flutter gen-l10n:*)", + "WebSearch", + "WebFetch(domain:fmtc.jaffaketchup.dev)", + "WebFetch(domain:pub.dev)", + "Bash(for lang in de es fr it)", + "Bash(do echo \"=== app_$lang.arb ===\")", + "Bash(done)", + "Read(//Users/dz0ny/.pub-cache/hosted/pub.dev/flutter_map_tile_caching-10.1.1/lib/src/store/**)", + "Read(//Users/dz0ny/.pub-cache/hosted/pub.dev/flutter_map_tile_caching-10.1.1/lib/**)", + "Bash(cat:*)", + "Read(//Users/dz0ny/.pub-cache/hosted/pub.dev/**)", + "Bash(for lang in hr sl de es fr it)", + "Bash(do)", + "Bash(wc:*)", + "Bash(for lang in de es fr it hr sl)", + "Bash(do echo \"=== $lang ===\")" ], "deny": [], "ask": [] diff --git a/.github/workflows/README.md b/.github/workflows/README.md new file mode 100644 index 0000000..e322707 --- /dev/null +++ b/.github/workflows/README.md @@ -0,0 +1,100 @@ +# CI/CD Workflows + +## Build Multi-Platform Workflow + +Automatically builds the MeshCore SAR app for Android, iOS, macOS, and Windows. + +### Triggers + +- **Push to main/develop**: Builds with version `1.0.0+1` (from pubspec.yaml) +- **Pull requests to main**: Test builds only +- **Version tags** (e.g., `v1.2.3`): Release builds with versioned artifacts +- **Manual**: Via GitHub Actions UI + +### Creating a Release + +1. **Ensure your local version is 1.0.0**: + ```bash + # pubspec.yaml should show: + version: 1.0.0+1 + ``` + +2. **Commit all changes**: + ```bash + git add . + git commit -m "Prepare for release" + git push origin main + ``` + +3. **Create and push a version tag**: + ```bash + git tag v1.2.3 + git push origin v1.2.3 + ``` + +4. **GitHub Actions will**: + - Temporarily patch version to `1.2.3+` in builds only + - Build Android APK & App Bundle + - Build iOS app (unsigned - configure secrets for signed IPA) + - Build macOS DMG + - Build Windows executable (ZIP) + - Create a draft GitHub release with all artifacts + +5. **Publish the release**: + - Go to GitHub Releases + - Edit the draft release + - Add release notes + - Publish + +### Build Artifacts + +| Platform | Artifact | Location | +|----------|----------|----------| +| Android APK | `app-release.apk` | `artifacts/android-apk/` | +| Android Bundle | `app-release.aab` | `artifacts/android-appbundle/` | +| macOS | `MeshCore-SAR.dmg` | `artifacts/macos-dmg/` | +| iOS | `Runner.app` | `artifacts/ios-build/` (unsigned) | +| Windows | `MeshCore-SAR-Windows.zip` | `artifacts/windows-executable/` | + +### iOS Code Signing (Optional) + +To build signed IPA files, add these repository secrets: + +1. **IOS_P12_BASE64**: Base64-encoded .p12 certificate + ```bash + base64 -i Certificate.p12 | pbcopy + ``` + +2. **IOS_P12_PASSWORD**: Password for the .p12 certificate + +3. **IOS_PROVISION_PROFILE_BASE64**: Base64-encoded provisioning profile + ```bash + base64 -i Profile.mobileprovision | pbcopy + ``` + +Then uncomment the signing steps in the workflow. + +### Version Numbering + +- **Local**: Always keep `pubspec.yaml` at `version: 1.0.0+1` +- **CI builds**: Uses `1.0.0+1` for regular commits +- **Release builds**: Uses `+` (e.g., `1.2.3+1729512345`) +- **No commits**: Version changes are temporary and never committed back + +### Troubleshooting + +**Build fails on macOS DMG creation**: +- The workflow tries `create-dmg` first, then falls back to `hdiutil` +- Check if app icon path exists at `macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png` + +**Windows build fails**: +- Ensure Windows desktop is properly configured in project +- Run locally: `flutter config --enable-windows-desktop && flutter build windows` + +**iOS build fails**: +- Check CocoaPods version and dependencies +- Review code signing configuration (currently set to `--no-codesign`) + +**Android build fails**: +- Verify Java 17 is compatible with your Gradle version +- Check `android/build.gradle` for minimum SDK requirements diff --git a/.github/workflows/build-multiplatform.yml b/.github/workflows/build-multiplatform.yml new file mode 100644 index 0000000..11e9e58 --- /dev/null +++ b/.github/workflows/build-multiplatform.yml @@ -0,0 +1,349 @@ +name: Build Multi-Platform + +on: + push: + branches: [main, develop] + tags: + - 'v*' + pull_request: + branches: [main] + workflow_dispatch: + +env: + FLUTTER_VERSION: '3.24.0' + +jobs: + # Update version from tag + update-version: + name: Update Version from Tag + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/v') + outputs: + version: ${{ steps.get_version.outputs.version }} + build_number: ${{ steps.get_version.outputs.build_number }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Get version from tag + id: get_version + run: | + # Extract version from tag (e.g., v1.2.3 -> 1.2.3) + VERSION=${GITHUB_REF#refs/tags/v} + echo "version=$VERSION" >> $GITHUB_OUTPUT + + # Generate build number from timestamp + BUILD_NUMBER=$(date +%s) + echo "build_number=$BUILD_NUMBER" >> $GITHUB_OUTPUT + + echo "Version: $VERSION" + echo "Build Number: $BUILD_NUMBER" + + - name: Update pubspec.yaml + run: | + VERSION="${{ steps.get_version.outputs.version }}" + BUILD_NUMBER="${{ steps.get_version.outputs.build_number }}" + + # Update version in pubspec.yaml + sed -i.bak "s/^version: .*/version: $VERSION+$BUILD_NUMBER/" pubspec.yaml + + echo "Updated pubspec.yaml:" + grep "^version:" pubspec.yaml + + - name: Upload updated pubspec + uses: actions/upload-artifact@v4 + with: + name: versioned-pubspec + path: pubspec.yaml + retention-days: 1 + + # Android APK Build + build-android: + name: Build Android APK + runs-on: ubuntu-latest + needs: [update-version] + if: always() && (needs.update-version.result == 'success' || needs.update-version.result == 'skipped') + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download versioned pubspec + if: startsWith(github.ref, 'refs/tags/v') + uses: actions/download-artifact@v4 + with: + name: versioned-pubspec + path: . + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: '17' + cache: 'gradle' + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ env.FLUTTER_VERSION }} + channel: 'stable' + cache: true + + - name: Install dependencies + run: flutter pub get + + - name: Generate localizations + run: flutter gen-l10n + + - name: Run analyzer + run: flutter analyze + + - name: Run tests + run: flutter test + + - name: Build APK + run: flutter build apk --release + + - name: Build App Bundle + run: flutter build appbundle --release + + - name: Upload APK + uses: actions/upload-artifact@v4 + with: + name: android-apk + path: build/app/outputs/flutter-apk/app-release.apk + retention-days: 30 + + - name: Upload App Bundle + uses: actions/upload-artifact@v4 + with: + name: android-appbundle + path: build/app/outputs/bundle/release/app-release.aab + retention-days: 30 + + # iOS IPA Build + build-ios: + name: Build iOS IPA + runs-on: macos-latest + needs: [update-version] + if: always() && (needs.update-version.result == 'success' || needs.update-version.result == 'skipped') + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download versioned pubspec + if: startsWith(github.ref, 'refs/tags/v') + uses: actions/download-artifact@v4 + with: + name: versioned-pubspec + path: . + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ env.FLUTTER_VERSION }} + channel: 'stable' + cache: true + + - name: Install dependencies + run: flutter pub get + + - name: Generate localizations + run: flutter gen-l10n + + - name: Install CocoaPods dependencies + run: | + cd ios + pod install + cd .. + + - name: Build iOS (No Codesign) + run: flutter build ios --release --no-codesign + + # For signed builds, uncomment and configure secrets: + # - name: Import Code Signing Certificates + # uses: apple-actions/import-codesign-certs@v2 + # with: + # p12-file-base64: ${{ secrets.IOS_P12_BASE64 }} + # p12-password: ${{ secrets.IOS_P12_PASSWORD }} + + # - name: Import Provisioning Profile + # run: | + # mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles + # echo "${{ secrets.IOS_PROVISION_PROFILE_BASE64 }}" | base64 --decode > ~/Library/MobileDevice/Provisioning\ Profiles/profile.mobileprovision + + # - name: Build IPA (Signed) + # run: flutter build ipa --release --export-options-plist=ios/ExportOptions.plist + + - name: Upload iOS Build + uses: actions/upload-artifact@v4 + with: + name: ios-build + path: build/ios/iphoneos/Runner.app + retention-days: 30 + + # Uncomment when signing is configured: + # - name: Upload IPA + # uses: actions/upload-artifact@v4 + # with: + # name: ios-ipa + # path: build/ios/ipa/*.ipa + # retention-days: 30 + + # macOS DMG Build + build-macos: + name: Build macOS DMG + runs-on: macos-latest + needs: [update-version] + if: always() && (needs.update-version.result == 'success' || needs.update-version.result == 'skipped') + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download versioned pubspec + if: startsWith(github.ref, 'refs/tags/v') + uses: actions/download-artifact@v4 + with: + name: versioned-pubspec + path: . + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ env.FLUTTER_VERSION }} + channel: 'stable' + cache: true + + - name: Enable macOS desktop + run: flutter config --enable-macos-desktop + + - name: Install dependencies + run: flutter pub get + + - name: Generate localizations + run: flutter gen-l10n + + - name: Build macOS app + run: flutter build macos --release + + - name: Create DMG + run: | + # Install create-dmg if not available + brew install create-dmg || true + + # Create DMG from the built app + create-dmg \ + --volname "MeshCore SAR" \ + --volicon "macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png" \ + --window-pos 200 120 \ + --window-size 800 400 \ + --icon-size 100 \ + --icon "MeshCore SAR.app" 200 190 \ + --hide-extension "MeshCore SAR.app" \ + --app-drop-link 600 185 \ + "MeshCore-SAR.dmg" \ + "build/macos/Build/Products/Release/meshcore_sar_app.app" || \ + # Fallback: simple DMG creation + hdiutil create -volname "MeshCore SAR" \ + -srcfolder build/macos/Build/Products/Release/meshcore_sar_app.app \ + -ov -format UDZO MeshCore-SAR.dmg + + - name: Upload macOS DMG + uses: actions/upload-artifact@v4 + with: + name: macos-dmg + path: MeshCore-SAR.dmg + retention-days: 30 + + - name: Upload macOS App + uses: actions/upload-artifact@v4 + with: + name: macos-app + path: build/macos/Build/Products/Release/meshcore_sar_app.app + retention-days: 30 + + # Windows Executable Build + build-windows: + name: Build Windows Executable + runs-on: windows-latest + needs: [update-version] + if: always() && (needs.update-version.result == 'success' || needs.update-version.result == 'skipped') + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download versioned pubspec + if: startsWith(github.ref, 'refs/tags/v') + uses: actions/download-artifact@v4 + with: + name: versioned-pubspec + path: . + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ env.FLUTTER_VERSION }} + channel: 'stable' + cache: true + + - name: Enable Windows desktop + run: flutter config --enable-windows-desktop + + - name: Install dependencies + run: flutter pub get + + - name: Generate localizations + run: flutter gen-l10n + + - name: Build Windows app + run: flutter build windows --release + + - name: Create installer (Optional - using Inno Setup) + shell: pwsh + run: | + # Download and install Inno Setup if needed + # This is optional - you can skip installer creation + Write-Host "Windows executable built successfully" + Write-Host "To create installer, configure Inno Setup script" + + - name: Archive Windows Build + shell: pwsh + run: | + Compress-Archive -Path build/windows/x64/runner/Release/* -DestinationPath MeshCore-SAR-Windows.zip + + - name: Upload Windows Executable + uses: actions/upload-artifact@v4 + with: + name: windows-executable + path: MeshCore-SAR-Windows.zip + retention-days: 30 + + # Create Release on Tag + create-release: + name: Create Release + needs: [build-android, build-ios, build-macos, build-windows] + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/v') + + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Create Release + uses: softprops/action-gh-release@v1 + with: + files: | + artifacts/android-apk/*.apk + artifacts/android-appbundle/*.aab + artifacts/macos-dmg/*.dmg + artifacts/windows-executable/*.zip + draft: true + generate_release_notes: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/CLAUDE.md b/CLAUDE.md index 07290f5..d1bd7bc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,695 +2,200 @@ AI assistant guide for the MeshCore SAR Flutter application. -## ⚠️ CRITICAL: Flutter Development Rules +## Table of Contents +1. [Critical Development Rules](#critical-development-rules) +2. [Quick Reference](#quick-reference) +3. [Project Structure](#project-structure) +4. [Protocol Reference](#protocol-reference) +5. [Architecture](#architecture) +6. [Common Tasks](#common-tasks) +7. [Build & Troubleshooting](#build--troubleshooting) +--- + +## Critical Development Rules + +### Flutter Process Management **NEVER run or kill Flutter processes:** -- DO NOT execute `flutter run` command -- DO NOT kill Flutter processes (`pkill flutter`, `killall flutter`) -- User manages Flutter development server - only make code changes -- Hot reload happens automatically when files are saved +- ❌ DO NOT execute `flutter run` command +- ❌ DO NOT kill Flutter processes (`pkill flutter`, `killall flutter`) +- ✅ User manages Flutter development server - only make code changes +- ✅ Hot reload happens automatically when files are saved + +### Key Architecture Constraints +- **Echo Detection**: Uses DJB2-style hash (NO crypto dependency needed) +- **Channel Messages**: NO ACKs (fire-and-forget flood routing) +- **Direct Messages**: Automatic ACKs via `PUSH_CODE_SEND_CONFIRMED` +- **SAR Markers**: MUST be sent to rooms, NOT public channel + +--- ## Quick Reference -**Project Type**: Flutter Mobile App (iOS 13+, Android API 21+) -**Architecture**: Provider-based state management + BLE communication -**Protocol**: MeshCore BLE Companion Radio (Little Endian byte order) +**Project**: Flutter Mobile App (iOS 13+, Android API 21+) +**Architecture**: Provider pattern + BLE communication +**Protocol**: MeshCore BLE Companion Radio (Little Endian) **Repository**: https://github.com/meshcore-dev/meshcore.js -**BLE Service UUIDs:** -- Service: `6E400001-B5A3-F393-E0A9-E50E24DCCA9E` -- RX (write): `6E400002-B5A3-F393-E0A9-E50E24DCCA9E` -- TX (notify): `6E400003-B5A3-F393-E0A9-E50E24DCCA9E` +### BLE UUIDs +``` +Service: 6E400001-B5A3-F393-E0A9-E50E24DCCA9E +RX (write): 6E400002-B5A3-F393-E0A9-E50E24DCCA9E +TX (notify): 6E400003-B5A3-F393-E0A9-E50E24DCCA9E +``` -**Key Dependencies:** -- flutter_blue_plus ^2.0.0 (BLE) -- flutter_map ^8.2.2 (mapping) -- provider ^6.1.0 (state) -- geolocator ^14.0.2 (GPS) +### Key Dependencies +```yaml +flutter_blue_plus: ^2.0.0 # BLE communication +flutter_map: ^8.2.2 # Mapping +provider: ^6.1.0 # State management +geolocator: ^14.0.2 # GPS tracking +``` -**Note:** No crypto dependencies required - echo detection uses a simple DJB2-style hash function +--- ## Project Structure ``` lib/ -├── l10n/ # Internationalization (i18n) -│ ├── app_localizations.dart # Generated localization class -│ ├── app_en.arb # English (default) -│ ├── app_hr.arb # Croatian (Hrvatski) -│ └── app_sl.arb # Slovenian (Slovenščina) -├── models/ # Data models +├── l10n/ # Internationalization (en, hr, sl) +│ ├── app_localizations.dart # Generated (DO NOT EDIT) +│ └── app_*.arb # Translation files +├── models/ # Data models │ ├── contact.dart, message.dart, sar_marker.dart -│ ├── map_drawing.dart # MapDrawing, LineDrawing, RectangleDrawing -│ ├── device_info.dart, room_login_state.dart, map_layer.dart -├── services/ # Business logic -│ ├── meshcore_ble_service.dart # BLE coordinator (399 lines) -│ ├── protocol/ # Frame parsing & building (628 lines) -│ ├── ble/ # Connection, commands, responses (963 lines) -│ ├── location_tracking_service.dart # GPS + mesh broadcast (501 lines) -│ ├── map_marker_service.dart # Marker generation + geodesic (518 lines) -│ └── validation_service.dart # Form validation (511 lines) -├── providers/ # State management -│ ├── connection_provider.dart # BLE connection state -│ ├── contacts_provider.dart # Contact list -│ ├── messages_provider.dart # Messages + SAR markers -│ ├── map_provider.dart # Map navigation -│ ├── drawing_provider.dart # Map drawing state -│ └── app_provider.dart # Coordinator (uses all above) -├── screens/ # UI screens (home, messages, contacts, map, settings, device_config, map_management, packet_log) -├── widgets/ # Reusable components -│ ├── map_markers.dart # Map marker rendering -│ ├── map/ # Map-specific widgets -│ │ ├── drawing_layer.dart # Drawing rendering on map -│ │ ├── drawing_toolbar.dart # Drawing UI controls -│ │ └── compass/ # Compass dialog components (fully localized) -│ ├── messages/, contacts/ # Feature-specific widgets (fully localized) -└── utils/ # Utilities - ├── sar_message_parser.dart # SAR marker parsing - └── drawing_message_parser.dart # Drawing message parsing +│ ├── map_drawing.dart # Drawing shapes +│ └── sent_message_tracker.dart # Echo detection +├── services/ # Business logic +│ ├── meshcore_ble_service.dart # BLE coordinator +│ ├── protocol/ # Frame parsing & building +│ ├── ble/ # Connection, commands, responses +│ ├── location_tracking_service.dart # GPS + broadcast +│ ├── map_marker_service.dart # Marker generation +│ └── validation_service.dart # Form validation +├── providers/ # State management +│ ├── connection_provider.dart # BLE state +│ ├── contacts_provider.dart # Contact list +│ ├── messages_provider.dart # Messages + SAR +│ ├── map_provider.dart # Map navigation +│ ├── drawing_provider.dart # Map drawings +│ └── app_provider.dart # Coordinator +├── screens/ # UI screens +│ └── (home, messages, contacts, map, settings, etc.) +├── widgets/ # Reusable components +│ ├── map_markers.dart +│ └── map/ # Map-specific widgets +└── utils/ # Utilities + ├── sar_message_parser.dart + └── drawing_message_parser.dart ``` -## MeshCore Protocol +--- -### Frame Delimiters -- **BLE**: Single characteristic value (link layer handles integrity) -- **USB**: `>` (0x3E) outbound, `<` (0x3C) inbound, 2-byte length (LE), then frame data -- **All uint32 values use Little Endian byte order** +## Protocol Reference -### Command Codes (App → Radio) +### Message Formats -| Code | Name | Description | -|------|------|-------------| -| 1 | CMD_APP_START | First command after connection → RESP_CODE_SELF_INFO(5) | -| 2 | CMD_SEND_TXT_MSG | Send text message to contact (DM) | -| 3 | CMD_SEND_CHANNEL_TXT_MSG | Send flood-mode text to channel | -| 4 | CMD_GET_CONTACTS | Sync contacts (optional 'since' param) | -| 5 | CMD_GET_DEVICE_TIME | Get device clock (epoch secs, UTC) | -| 6 | CMD_SET_DEVICE_TIME | Set device clock | -| 7 | CMD_SEND_SELF_ADVERT | Send Advertisement packet | -| 8 | CMD_SET_ADVERT_NAME | Update node name in adverts | -| 9 | CMD_ADD_UPDATE_CONTACT | Add/modify contact | -| 10 | CMD_SYNC_NEXT_MESSAGE | Get next text message from queue | -| 11 | CMD_SET_RADIO_PARAMS | Save radio parameters | -| 12 | CMD_SET_RADIO_TX_POWER | Set radio TX power | -| 13 | CMD_RESET_PATH | Reset out_path for contact | -| 14 | CMD_SET_ADVERT_LATLON | Update lat/lon in adverts | -| 15 | CMD_REMOVE_CONTACT | Remove contact | -| 16 | CMD_SHARE_CONTACT | Share contact via zero-hop advert | -| 17 | CMD_EXPORT_CONTACT | Export contact as business card | -| 18 | CMD_IMPORT_CONTACT | Import contact from business card | -| 19 | CMD_REBOOT | Reboot companion device | -| 20 | CMD_GET_BATT_AND_STORAGE | Get battery mV and storage stats | -| 21 | CMD_SET_TUNING_PARAMS | Set tuning parameters | -| 22 | CMD_DEVICE_QUERY | First command to send → RESP_CODE_DEVICE_INFO(13) | -| 25 | CMD_SEND_RAW_DATA | Transmit PAYLOAD_TYPE_RAW_CUSTOM | -| 26 | CMD_SEND_LOGIN | Send login to repeater/room | -| 27 | CMD_SEND_STATUS_REQ | Send status request | -| 36 | CMD_SEND_TRACE_PATH | Initiate TRACE with SNR collection | -| 37 | CMD_SET_DEVICE_PIN | Set BLE PIN code | -| 38 | CMD_SET_OTHER_PARAMS | Set various parameters | -| 39 | CMD_SEND_TELEMETRY_REQ | Request telemetry (deprecated) | -| 40 | CMD_GET_CUSTOM_VARS | Retrieve custom variables | -| 41 | CMD_SET_CUSTOM_VAR | Set single custom variable | -| 42 | CMD_GET_ADVERT_PATH | Query last advert path | -| 43 | CMD_GET_TUNING_PARAMS | Get airtime-factor & rx-delay | -| 50 | CMD_SEND_BINARY_REQ | Binary request (preferred over 39) | -| 51 | CMD_FACTORY_RESET | Erase flash file system | +#### SAR Marker Format +``` +S::,: -### Response Codes (Radio → App) +Emojis: + 🧑 or 👤 → Found Person + 🔥 → Fire Location + 🏕️ or ⛺ → Staging Area -| Code | Name | Description | -|------|------|-------------| -| 0 | RESP_CODE_OK | Success | -| 1 | RESP_CODE_ERR | Error (includes err_code) | -| 2 | RESP_CODE_CONTACTS_START | Start contacts sync | -| 3 | RESP_CODE_CONTACT | Single contact info | -| 4 | RESP_CODE_END_OF_CONTACTS | End contacts sync | -| 5 | RESP_CODE_SELF_INFO | Node's own information | -| 6 | RESP_CODE_SENT | Message sent with ACK/TAG | -| 7 | RESP_CODE_CONTACT_MSG_RECV | Contact message received | -| 8 | RESP_CODE_CHANNEL_MSG_RECV | Channel message received | -| 9 | RESP_CODE_CURR_TIME | Current device time | -| 10 | RESP_CODE_NO_MORE_MESSAGES | Message queue empty | -| 11 | RESP_CODE_EXPORT_CONTACT | Contact export data | -| 12 | RESP_CODE_BATT_AND_STORAGE | Battery and storage info | -| 13 | RESP_CODE_DEVICE_INFO | Device firmware/hardware info | -| 21 | RESP_CODE_CUSTOM_VARS | Custom variables state | -| 22 | RESP_CODE_ADVERT_PATH | Last advert path | +Examples: + S:🧑:37.7749,-122.4194 + S:🔥:40.7128,-74.0060:Large wildfire spreading rapidly +``` -### Push Notifications (Radio → App, Async) +#### Map Drawing Format +``` +D: -| Code | Name | Description | -|------|------|-------------| +Line: D:{"t":0,"c":0,"p":[lat1,lon1,lat2,lon2]} +Rectangle: D:{"t":1,"c":1,"b":[topLat,topLon,botLat,botLon]} + +Fields: + t = type (0=line, 1=rectangle) + c = color index (0-7: red,blue,green,yellow,orange,purple,pink,cyan) + p = points array (flat) + b = bounds array (rectangles only) +``` + +#### Cayenne LPP Format +``` +[Channel][Type][Data...] + +Types: + 0x88 (136) → GPS: lat/lon/alt (int32/10000, int32/10000, int32/100) + 0x67 (103) → Temperature (int16/10 for °C) + 0x02 (2) → Analog Input (uint16/100 for volts/battery) +``` + +### Command Quick Reference + +| Code | Command | Response | Description | +|------|---------|----------|-------------| +| 1 | CMD_APP_START | RESP_CODE_SELF_INFO (5) | First command after connection | +| 2 | CMD_SEND_TXT_MSG | RESP_CODE_SENT (6) | Send DM to contact (with ACK) | +| 3 | CMD_SEND_CHANNEL_TXT_MSG | RESP_CODE_SENT (6) | Broadcast to channel (NO ACK) | +| 4 | CMD_GET_CONTACTS | RESP_CODE_CONTACTS_START (2) | Sync contact list | +| 10 | CMD_SYNC_NEXT_MESSAGE | RESP_CODE_CONTACT_MSG_RECV (7) | Pull next message from queue | +| 22 | CMD_DEVICE_QUERY | RESP_CODE_DEVICE_INFO (13) | Get device firmware/hardware info | +| 26 | CMD_SEND_LOGIN | PUSH_CODE_LOGIN_SUCCESS (0x85) | Login to room server | + +### Push Notifications (Async) + +| Code | Name | Purpose | +|------|------|---------| | 0x80 | PUSH_CODE_ADVERT | New advertisement received | -| 0x81 | PUSH_CODE_PATH_UPDATED | Contact received new path | -| 0x82 | PUSH_CODE_SEND_CONFIRMED | Message ACK received | -| 0x83 | PUSH_CODE_MSG_WAITING | New text message received | -| 0x84 | PUSH_CODE_RAW_DATA | PAYLOAD_TYPE_RAW_CUSTOM received | -| 0x85 | PUSH_CODE_LOGIN_SUCCESS | Login successful | -| 0x86 | PUSH_CODE_LOGIN_FAIL | Login failed | -| 0x87 | PUSH_CODE_STATUS_RESPONSE | Status response received | -| 0x88 | PUSH_CODE_LOG_RX_DATA | Debug: raw OTA packet (diagnostic) | -| 0x89 | PUSH_CODE_TRACE_DATA | TRACE packet end of path | -| 0x8A | PUSH_CODE_NEW_ADVERT | New contact advert | -| 0x8B | PUSH_CODE_TELEMETRY_RESPONSE | Telemetry response | -| 0x8C | PUSH_CODE_BINARY_RESPONSE | Binary response | - -### CRITICAL: PUSH_CODE_LOG_RX_DATA (0x88) - Diagnostic Packet Capture - -**⚠️ IMPORTANT: This is an always-on diagnostic feature when app is connected** - -**Purpose**: Real-time packet capture of ALL radio traffic for debugging and network analysis - -**Trigger**: Automatically sent for EVERY packet received by the radio, before validation -- Triggered in `Dispatcher::checkRecv()` → `logRxRaw()` virtual hook -- No filtering, throttling, or configuration options -- Even malformed/incomplete packets are captured - -**Packet Format** (3 + raw_packet_length bytes): - -| Byte | Field | Description | -|------|-------|-------------| -| 0 | Code | `PUSH_CODE_LOG_RX_DATA` (0x88) | -| 1 | SNR | Signal-to-Noise Ratio: `(int8_t)(snr_db * 4)` - decode by dividing by 4.0 | -| 2 | RSSI | Received Signal Strength: `(int8_t)(rssi_dbm)` - signed byte | -| 3...N | raw_data | Complete raw packet as received from radio (up to 255 bytes) | - -**Example Decoding**: -```dart -final snrRaw = data[0]; -final snrDb = (snrRaw.toSigned(8)) / 4.0; // e.g., 0x14 → 5.0 dB -final rssiDbm = data[1].toSigned(8); // e.g., 0xC8 → -56 dBm -final rawPacket = data.sublist(2); // Complete LoRa packet -``` - -**Behavior**: -- **Always Active**: Automatically enabled when BLE/USB/WiFi client connects -- **NOT User-Configurable**: No runtime enable/disable command exists -- **Only Way to Disable**: Disconnect the app from companion radio -- **Bandwidth Impact**: Can generate significant traffic in busy mesh networks -- **Frame Size Limit**: Only sent if `packet_length + 3 <= 172` (BLE MTU constraint) - -**Use Cases**: -1. **Packet Sniffer**: Capture all mesh network traffic in range -2. **Signal Analysis**: Monitor SNR/RSSI for link quality assessment -3. **Network Diagnostics**: Identify interference, collisions, malformed packets -4. **Protocol Development**: Analyze packet structures and timing -5. **Coverage Testing**: Map signal strength across geographic areas - -**Current Implementation** (lib/services/ble/ble_response_handler.dart:409): -- Parses SNR and RSSI from diagnostic packets -- Calculates entropy to detect encrypted vs. plaintext packets -- Stores in packet log (`_packetLogs`) for viewing in Packet Log screen -- Accessible via `screens/packet_log_screen.dart` - -**Security Consideration**: Raw packet capture means ALL traffic is visible (encrypted payloads are still captured at radio level) - -**Reference Files**: -- Hook Definition: `/Users/dz0ny/meshcore-sar/MeshCore/src/Dispatcher.h` (line 149) -- Call Site: `/Users/dz0ny/meshcore-sar/MeshCore/src/Dispatcher.cpp` (line 119) -- Companion Implementation: `/Users/dz0ny/meshcore-sar/MeshCore/examples/companion_radio/MyMesh.cpp` (lines 237-248) - -### CRITICAL: Public Message Echo Detection Using PUSH_CODE_LOG_RX_DATA - -**⚠️ IMPORTANT: You CAN detect when your broadcast messages are received and rebroadcast by other nodes** - -**The Problem**: Public channel messages don't have explicit ACKs (fire-and-forget). How do we know if anyone received them? - -**The Solution**: Echo detection using `PUSH_CODE_LOG_RX_DATA` raw packet matching! - -**How It Works:** - -1. **Deterministic Encryption**: Public messages use AES128-ECB encryption - - Same plaintext + same channel key = **identical encrypted output** - - When node B receives your message and rebroadcasts it, the packet is **byte-for-byte identical** - - You can detect this by comparing raw packet data! - -2. **Public Message Packet Structure**: - - **Plaintext Payload (before encryption):** - ``` - [4 bytes] = Timestamp (uint32_t, little-endian) - [1 byte] = TXT_TYPE (0x00 = plain, 0x01 = CLI, 0x02 = signed) - [variable] = "sender_name: message_text" - [0-15 bytes]= Zero padding to 16-byte boundary - ``` - - **Encrypted Wire Format (in PUSH_CODE_LOG_RX_DATA):** - ``` - [1 byte] = Channel hash (identifies which channel) - [2 bytes] = MAC (HMAC-SHA256 truncated to 2 bytes) - [16+ bytes] = AES128-ECB encrypted payload - ``` - -3. **Echo Detection Algorithm**: - ``` - When sending public message: - 1. Store encrypted payload (channel_hash + MAC + ciphertext) - 2. Calculate SHA256 hash for fast lookup (8 bytes sufficient) - 3. Set expiry (e.g., 5 minutes - messages won't echo after that) - - When receiving PUSH_CODE_LOG_RX_DATA: - 1. Extract raw packet data (skip SNR/RSSI bytes) - 2. Calculate hash of raw packet - 3. Check if hash matches any recently sent message - 4. If match found → ECHO DETECTED! Someone rebroadcast your message - 5. Increment ACK/echo counter for that message - ``` - -4. **What Echoes Mean**: - - **Echo detected**: At least one node received your broadcast AND rebroadcast it - - **Multiple echoes**: Multiple nodes received and rebroadcast (indicates good mesh coverage) - - **No echoes**: Either no nodes in range, or message not rebroadcast (not necessarily failure) - - **Echo count ≠ exact receiver count**: One node can produce multiple echoes via different paths - -5. **Implementation Strategy**: - - **Data Structure**: - ```dart - class SentMessageTracker { - final String messageId; - final String packetHashHex; // Simple hash of packet for O(1) lookup - final DateTime sentTime; - final DateTime expiryTime; - int echoCount = 0; - Set uniqueEchoPaths = {}; // Track different signal paths - } - ``` - - **Storage**: - - Keep last 50-100 sent messages in memory - - Use hash map for O(1) lookup: `Map` - - Auto-cleanup expired entries (5-10 minute TTL) - - **Matching**: - ```dart - /// Simple hash function for packet identification (no crypto dependency) - String _simplePacketHash(Uint8List packet) { - // Use DJB2-style hash with length and bytes from start/middle/end - // Sufficient for short-lived echo detection (5 min TTL) - int hash = packet.length; - // Mix in bytes from strategic positions - for (int i = 0; i < packet.length && i < 8; i++) { - hash = ((hash << 5) - hash) + packet[i]; - hash = hash & 0xFFFFFFFF; // Keep 32-bit - } - // ... sample from middle and end - return hash.toRadixString(16).padLeft(8, '0'); - } - - void _handleLogRxData(BufferReader reader) { - final snrRaw = data[0]; - final rssiDbm = data[1]; - final rawPacket = data.sublist(2); - - // Calculate simple hash (no crypto package needed!) - final packetHashHex = _simplePacketHash(rawPacket); - - // Check for echo - final tracker = _sentMessageTrackers[packetHashHex]; - if (tracker != null && !tracker.isExpired) { - tracker.echoCount++; - tracker.uniqueEchoPaths.add('${snrRaw}_${rssiDbm}'); - onMessageEcho?.call(tracker.messageId, tracker.echoCount); - } - } - ``` - -6. **UI Implications**: - - Show echo count instead of "Broadcast" for channel messages - - Display: "Rebroadcast by 3 nodes" or "No echoes yet" - - Color coding: Green (echoes detected), Yellow (waiting), Gray (expired) - - Tap to show echo details: SNR/RSSI of each echo, timing, etc. - -7. **Limitations & Considerations**: - - **Not a guaranteed delivery count**: Echoes indicate rebroadcast, not unique receivers - - **Network topology dependent**: Dense networks → more echoes - - **Time window**: Only detects echoes while app is connected and listening - - **False negatives possible**: Messages may be received but not rebroadcast if: - - Receiver's hop limit reached - - Receiver already saw packet via another path - - Network congestion/collision - - **Timestamp uniqueness**: `getCurrentTimeUnique()` auto-increments to prevent collisions - -8. **Advanced Features**: - - **Signal quality heatmap**: Map echo SNR/RSSI to visualize coverage - - **Mesh health monitoring**: Track echo rates over time - - **Reliability score**: Calculate delivery probability based on historical echoes - - **Path diversity**: Count unique echo paths (different SNR/RSSI signatures) - -**Reference Files:** -- Send group message: `/Users/dz0ny/meshcore-sar/MeshCore/src/helpers/BaseChatMesh.cpp` (lines 379-398) -- Encryption: `/Users/dz0ny/meshcore-sar/MeshCore/src/Mesh.cpp` (lines 509-527) -- Packet hashing: `/Users/dz0ny/meshcore-sar/MeshCore/src/Packet.cpp` (lines 17-26) -- AES implementation: `/Users/dz0ny/meshcore-sar/MeshCore/src/Utils.cpp` (lines 63-72) - -### Echo Detection Implementation Status - -**✅ FULLY IMPLEMENTED AND PRODUCTION-READY** - -The echo detection feature is **100% complete** with intelligent packet identification using the sender's node hash from the packet structure. No firmware changes required! - -**Brilliant Discovery - Sender Identification in Packet Structure:** - -The raw packet structure contains the sender's identity in an **unencrypted field**: - -``` -Packet Structure for PAYLOAD_TYPE_GRP_TXT (0x05): -[Byte 0] = Header (route type + payload type + version) -[Byte 1] = Path length -[Byte 2] = Path[0] = SENDER'S NODE HASH (first byte of sender's public key) ✅ -[Byte 3+] = Rest of path + encrypted payload -``` - -**How Echo Detection Works:** - -1. **Initialization** (on connection): - - Receive `RESP_CODE_SELF_INFO` with our public key - - Extract **our node hash** (first byte of public key) - - Store for packet identification - -2. **Sending a Message**: - - User sends channel message → `trackSentMessage(messageId)` called - - Tracker created with status "pending" (waiting for packet capture) - -3. **Packet Capture** (via `PUSH_CODE_LOG_RX_DATA`): - - Radio sends raw packet data (typically within 50-200ms) - - Extract header byte: `payloadType = (header >> 2) & 0x0F` - - Check if GRP_TXT packet: `payloadType == 0x05` - - Extract sender hash: `senderNodeHash = packet[2]` - - **If sender hash matches our node hash** → This is OUR packet! - - Calculate simple hash of entire packet (DJB2-style, no crypto dependency) - - Store tracker by packet hash for echo detection - -4. **Echo Detection**: - - Future `PUSH_CODE_LOG_RX_DATA` packets arrive - - Calculate packet hash using simple hash function - - Match against stored trackers (O(1) lookup) - - If match found → **Echo detected!** Another node rebroadcast our message - - Increment echo count, track SNR/RSSI signature - - Notify UI → Shows "Rebroadcast by X nodes" - -**Implementation Details:** - -1. **Data Models** (`lib/models/sent_message_tracker.dart`, `lib/models/message.dart`) - - `SentMessageTracker`: Tracks sent messages with simple packet hashes (no crypto dependency) - - `Message.echoCount` and `Message.firstEchoAt`: Track echo statistics - - `Message.echoStatusText`: Returns "Rebroadcast by X nodes" or "Broadcast (no echoes)" - -2. **Echo Detection Engine** (`lib/services/ble/ble_response_handler.dart`) - - `_simplePacketHash()`: DJB2-style hash function (replaces SHA256, no crypto package needed) - - `setOurNodeHash()`: Stores our node hash for packet identification - - `_associatePacketWithSentMessage()`: Smart packet matching using node hash - - `_checkForEcho()`: Matches received packets against sent message hashes (O(1) lookup) - - `trackSentMessage()`: Stores message ID when sending - - Automatic cleanup: 5-minute TTL, max 100 tracked messages - - Tracks unique echo paths via SNR/RSSI signatures - -3. **Complete Callback Chain:** - ``` - BleResponseHandler.onMessageEchoDetected (packet matching) - ↓ - MeshCoreBleService.onMessageEchoDetected (service layer) - ↓ - ConnectionProvider.onMessageEchoDetected (provider layer) - ↓ - AppProvider (wires to MessagesProvider) - ↓ - MessagesProvider.handleMessageEcho() (updates message state) - ↓ - UI auto-updates via notifyListeners() - ``` - -4. **UI Integration:** - - Message widgets automatically show echo count via `deliveryStatusText` - - "Broadcast (no echoes)" → No rebroadcasts detected yet - - "Rebroadcast by 1 node" → One node rebroadcast the message - - "Rebroadcast by X nodes" → Multiple nodes rebroadcast - -**Example Log Output:** - -``` -🔑 [Echo] Our node hash set to: 0xb8 -📤 [Echo] Tracking message 1760818280435_channel_sent, will capture next packet within 500ms -📦 [Echo] Captured OUR packet (node hash match!) - Message ID: 1760818280435_channel_sent - Sender hash: 0xb8 - Time delta: 147ms - Packet hash: a1b2c3d4e5f6... - Now tracking for echoes... -🔊 [Echo] Detected echo for message 1760818280435_channel_sent: count=1 -``` - -**Why This Solution Is Excellent:** - -✅ **No firmware changes required** - Uses existing packet structure -✅ **Reliable identification** - Explicit sender hash in packet (byte 2) -✅ **No timing assumptions** - Works even with delayed packets -✅ **Handles rapid sends** - Each packet uniquely identified -✅ **Production-ready** - Tested and functional -✅ **Efficient** - O(1) hash lookup for echo matching -✅ **Automatic cleanup** - 5-minute TTL prevents memory leaks - -**Files Modified for Echo Detection:** -- `lib/models/sent_message_tracker.dart` - NEW model for tracking sent messages -- `lib/models/message.dart` - Added `echoCount` and `firstEchoAt` fields -- `lib/services/ble/ble_response_handler.dart` - Core detection logic with node hash matching -- `lib/services/meshcore_ble_service.dart` - Callback wiring + node hash extraction -- `lib/providers/connection_provider.dart` - Provider callback declaration -- `lib/providers/app_provider.dart` - Wire echo callback to MessagesProvider -- `lib/providers/messages_provider.dart` - `handleMessageEcho()` method -- `pubspec.yaml` - Added `crypto: ^3.0.3` dependency - -**Testing Instructions:** - -**Setup:** -1. Ensure you have 2+ MeshCore devices in range -2. Connect Device A (your device) to the app -3. Wait for `RESP_CODE_SELF_INFO` → Look for log: `🔑 [Echo] Our node hash set to: 0xXX` - -**Test Echo Detection:** -1. Send a public channel message from Device A: "test message" -2. Watch logs for packet capture: - ``` - 📤 [Echo] Tracking message ... will capture next packet within 500ms - 📦 [Echo] Captured OUR packet (node hash match!) - Sender hash: 0xXX - Packet hash: abc123... - ``` -3. Device B receives and rebroadcasts the message -4. Device A detects echo: - ``` - 🔊 [Echo] Detected echo for message ...: count=1 - ``` -5. UI automatically updates to show: **"Rebroadcast by 1 node"** -6. Multiple devices → **"Rebroadcast by X nodes"** - -**Verification:** -- Check message delivery status shows echo count -- Each unique rebroadcast increments the counter -- SNR/RSSI tracked for each echo path -- Echoes expire after 5 minutes - -**Performance Characteristics:** -- Packet identification: O(1) - byte comparison at offset 2 -- Hash calculation: O(n) where n = packet length (~38-200 bytes) -- Echo lookup: O(1) via HashMap with SHA256 hash key -- Memory: ~150 bytes per tracked message, max 100 messages = ~15KB -- Cleanup: Automatic on every check + when tracker limit exceeded -- Window: 1-second correlation window for initial packet capture -- TTL: 5-minute expiry for echo tracking +| 0x82 | PUSH_CODE_SEND_CONFIRMED | Message ACK received (DMs only) | +| 0x83 | PUSH_CODE_MSG_WAITING | New message in queue → sync it | +| 0x88 | PUSH_CODE_LOG_RX_DATA | **Raw packet capture (always-on diagnostic)** | +| 0x85 | PUSH_CODE_LOGIN_SUCCESS | Room login successful | +| 0x86 | PUSH_CODE_LOGIN_FAIL | Room login failed | ### Constants -**ADV_TYPE (Contact Type):** -- 0: ADV_TYPE_NONE (unknown/invalid) -- 1: ADV_TYPE_CHAT (team member, shown on map) -- 2: ADV_TYPE_REPEATER (network repeater) -- 3: ADV_TYPE_ROOM (communication room/server) - -**TXT_TYPE (Message Type):** -- 0: TXT_TYPE_PLAIN (plain text) -- 1: TXT_TYPE_CLI_DATA (CLI command) -- 2: TXT_TYPE_SIGNED_PLAIN (plain text + extra 4 bytes of sender's public key for verification) - -**ERR_CODE:** -- 1: ERR_CODE_UNSUPPORTED_CMD -- 2: ERR_CODE_NOT_FOUND -- 3: ERR_CODE_TABLE_FULL -- 4: ERR_CODE_BAD_STATE -- 5: ERR_CODE_FILE_IO_ERROR -- 6: ERR_CODE_ILLEGAL_ARG - -### CRITICAL: Channels vs. Rooms - -**Channels** (numeric identifiers): -- Channel 0 = "Public Channel" (default flood-mode broadcast) -- Channel 1+ = Reserved for future -- **Ephemeral** - messages NOT persisted -- Use `CMD_SEND_CHANNEL_TXT_MSG` (3) -- **⚠️ MUST be configured before use** with `CMD_SET_CHANNEL` (32) - - Format: `[cmd(1)][channel_idx(1)][name(32)][secret(16)]` - - **Default public channel secret (128-bit)**: - - Hex: `8b3387e9c5cdea6ac9e5edbaa115cd72` - - Base64: `izOH6cXN6mrJ5e26oRXNcg==` - - Source: [MeshCore FAQ](https://github.com/meshcore-dev/MeshCore/blob/main/docs/faq.md) - - **Configuration**: Most firmware versions have channel 0 pre-configured - - App attempts to configure via `CMD_SET_CHANNEL` during init - - If command times out, channel is likely pre-configured (this is normal) - - If not pre-configured, radio returns `ERR_CODE_NOT_FOUND` (2) on send attempts - -**Rooms** (ADV_TYPE_ROOM contacts): -- Named contacts with public keys -- **Persistent and immutable storage** -- Use `CMD_SEND_TXT_MSG` with room's public key (direct message) -- Optional: Login with `CMD_SEND_LOGIN` to read stored messages - -**SAR Message Routing:** -- **SAR markers MUST be sent to rooms, NOT public channel** -- Rooms provide reliable delivery and storage for critical SAR data - -### CRITICAL: ACK Behavior - Channels vs. Direct Messages - -**⚠️ IMPORTANT: Channel Messages DO NOT Generate ACKs** - -**Channel Messages (Public Channel):** -- `CMD_SEND_CHANNEL_TXT_MSG` uses **fire-and-forget flood routing** -- **NO individual ACKs** from receivers -- Messages broadcast to all nearby nodes using shared channel encryption -- All subscribers in range receive and decrypt, but **do NOT acknowledge** -- Rationale: Multiple receivers would cause ACK explosion on mesh network -- Reliability: Best-effort delivery only - -**Direct Messages (Contact/Room DMs):** -- `CMD_SEND_TXT_MSG` to specific contact's public key -- Recipient **automatically generates ACK packet** when message received -- ACK format: 4-byte checksum = `SHA256(timestamp + text + sender_pubkey)` → first 4 bytes -- ACK routed back via same/reciprocal path using `PAYLOAD_TYPE_ACK (0x03)` -- Companion radio sends `PUSH_CODE_SEND_CONFIRMED (0x82)` when ACK received -- Multi-hop retry: Optional extra ACK transmissions at 300ms intervals for reliability - -**Room Server Messages (Special Case):** -- Messages to room server (ADV_TYPE_ROOM) are sent as DMs -- Room server ACKs when message is stored successfully -- When room server pushes stored messages to clients, each client ACKs back -- Room tracks pending ACKs per client with 12s timeout (flood) or 4+s (direct) - -**ACK Checksum Calculation:** +#### Contact Types (ADV_TYPE) ``` -SHA256_first_4_bytes( - timestamp (4 bytes) + - flags (1 byte) + - message_text (N bytes) + - sender_public_key (32 bytes) -) +0 = ADV_TYPE_NONE # Unknown/invalid +1 = ADV_TYPE_CHAT # Team member (shown on map) +2 = ADV_TYPE_REPEATER # Network repeater +3 = ADV_TYPE_ROOM # Communication room/server ``` -**UI Implications:** -- Channel messages: Show "Broadcast" status (no ACK count) -- Direct messages: Show ACK status when `PUSH_CODE_SEND_CONFIRMED` received -- Room messages: Show ACK when room server confirms storage - -**Reference Files:** -- Protocol: `/Users/dz0ny/meshcore-sar/MeshCore/docs/payloads.md` (lines 58-65) -- Implementation: `/Users/dz0ny/meshcore-sar/MeshCore/src/Mesh.cpp` (lines 348-374, 529-556) -- Client: `/Users/dz0ny/meshcore-sar/MeshCore/src/helpers/BaseChatMesh.cpp` (lines 312-331) -- Room Server: `/Users/dz0ny/meshcore-sar/MeshCore/examples/simple_room_server/MyMesh.cpp` (lines 53-113) - -### Room Login Protocol Flow (CRITICAL) - -1. **Client sends `CMD_SEND_LOGIN` (26)**: Radio internally generates sender_timestamp and sync_since -2. **Room server processes login**: Validates password, stores sync_since, delays first push 2000ms -3. **Client receives response**: `PUSH_CODE_LOGIN_SUCCESS` (0x85) or `PUSH_CODE_LOGIN_FAIL` (0x86) -4. **Room server auto-pushes messages**: Round-robin every 1200ms, sends messages where post_timestamp > sync_since -5. **Client receives pushed messages**: `PUSH_CODE_MSG_WAITING` (0x83) → call `CMD_SYNC_NEXT_MESSAGE` (10) - -**Implementation Rules:** -- ❌ DO NOT call `syncAllMessages()` after `PUSH_CODE_LOGIN_SUCCESS` -- ✅ DO wait for `PUSH_CODE_MSG_WAITING` push notifications -- ✅ DO call `syncNextMessage()` when `onMessageWaiting` fires - -### Cayenne LPP Format - -Format: `[Channel] [Type] [Data...]` - -**Supported Types:** -- 136 (0x88): GPS Location (lat/lon/alt: int32/10000, int32/10000, int32/100) -- 103 (0x67): Temperature (int16/10 for °C) -- 2 (0x02): Analog Input (uint16/100 for volts, used for battery) - -### SAR Message Format - -Format: `S::,:` - -**Recognized Emojis:** -- 🧑 or 👤: Found Person -- 🔥: Fire Location -- 🏕️ or ⛺: Staging Area - -**Rules:** -- Must start with `S:` -- Single emoji after first colon -- Comma-separated lat/lon coordinates -- Optional message after third colon (displayed in message bubble) -- No spaces in coordinates section - -**Examples:** -- `S:🧑:37.7749,-122.4194` - Basic SAR marker -- `S:🔥:40.7128,-74.0060:Large wildfire spreading rapidly` - With message -- `S:🏕️:34.0522,-118.2437:Base camp established, supplies available` - With detailed note - -**Message Display:** -- SAR markers shown with highlighted colored bubble -- Emoji, type name, and coordinates always displayed -- Optional message shown in secondary container below coordinates -- Tap to navigate to location on map - -### Map Drawing Message Format - -Format: `D:` - -**Ultra-Compact JSON Format:** -- **Prefix**: `D:` identifies drawing messages -- **Sender**: Extracted from packet metadata (not in JSON) -- **Type field (`t`)**: Shape type as integer - - `0`: Line drawing - - `1`: Rectangle drawing -- **Color field (`c`)**: Color index (0-7) - - `0`: Red, `1`: Blue, `2`: Green, `3`: Yellow - - `4`: Orange, `5`: Purple, `6`: Pink, `7`: Cyan -- **Points field (`p`)**: Flat array of coordinates `[lat1,lon1,lat2,lon2,...]` -- **Bounds field (`b`)**: Rectangle bounds `[topLat,topLon,botLat,botLon]` - -**Example Line Drawing (red, 2 points):** -```json -D:{"t":0,"c":0,"p":[45.123,-122.456,45.234,-122.567]} +#### Message Types (TXT_TYPE) +``` +0 = TXT_TYPE_PLAIN # Plain text +1 = TXT_TYPE_CLI_DATA # CLI command +2 = TXT_TYPE_SIGNED_PLAIN # Text + 4-byte pubkey signature ``` -**Example Rectangle Drawing (blue):** -```json -D:{"t":1,"c":1,"b":[45.1,-122.5,45.2,-122.4]} +#### Error Codes (ERR_CODE) +``` +1 = ERR_CODE_UNSUPPORTED_CMD +2 = ERR_CODE_NOT_FOUND +3 = ERR_CODE_TABLE_FULL +4 = ERR_CODE_BAD_STATE +5 = ERR_CODE_FILE_IO_ERROR +6 = ERR_CODE_ILLEGAL_ARG ``` -**Implementation Details:** -- Models: `lib/models/map_drawing.dart` (MapDrawing, LineDrawing, RectangleDrawing) -- Parser: `lib/utils/drawing_message_parser.dart` (DrawingMessageParser) -- Provider: `lib/providers/drawing_provider.dart` (DrawingProvider) -- Colors: 8 predefined colors mapped to indices for bandwidth efficiency -- Local persistence uses full JSON format with timestamps and IDs -- Network transmission uses ultra-compact format (~37% size reduction) +--- -## State Management Architecture +## Architecture ### Provider Hierarchy ``` MultiProvider -├── ConnectionProvider # BLE connection state -├── ContactsProvider # Contact list -├── MessagesProvider # Messages + SAR markers -├── MapProvider # Map navigation -├── DrawingProvider # Map drawing state -└── AppProvider # Coordinator (uses all above) +├── ConnectionProvider # BLE connection state +├── ContactsProvider # Contact list management +├── MessagesProvider # Messages + SAR markers +├── MapProvider # Map navigation state +├── DrawingProvider # Map drawing state +└── AppProvider # Coordinator (wires everything) ``` ### Event Flow @@ -704,295 +209,408 @@ BLE Device → MeshCoreBleService → ConnectionProvider → AppProvider UI ``` -**Drawing Message Flow:** +### Contact Path Status + +**outPathLen** indicates routing mode: +- **-1 (0xFF)**: Path unknown → **Flood mode** (broadcast to all neighbors) +- **0**: Direct connection → **Direct mode** (zero hops, best quality) +- **1-64**: Multi-hop path → **Direct mode** (uses learned routing) + +**Map Display**: Only `ContactType.chat` contacts with valid GPS coordinates shown. + +### Channels vs. Rooms + +**Channels** (numeric identifiers): +- Channel 0 = "Public Channel" (default broadcast) +- **Ephemeral** - messages NOT persisted +- Uses `CMD_SEND_CHANNEL_TXT_MSG` (3) +- **NO ACKs** - fire-and-forget flood routing +- Pre-configured with secret: `8b3387e9c5cdea6ac9e5edbaa115cd72` (hex) + +**Rooms** (ADV_TYPE_ROOM contacts): +- Named contacts with public keys +- **Persistent storage** on room server +- Uses `CMD_SEND_TXT_MSG` (2) with room's public key +- **Automatic ACKs** when messages stored +- Login via `CMD_SEND_LOGIN` (26) to receive stored messages + +**SAR Routing**: SAR markers MUST go to rooms for reliable delivery. + +### Room Login Protocol + ``` -User draws → DrawingProvider → DrawingToolbar (share) → ConnectionProvider (BLE) - ↓ -Remote User ← UI ← DrawingProvider ← AppProvider ← ConnectionProvider ← BLE Device +1. Send CMD_SEND_LOGIN (26) + ↓ +2. Radio generates sender_timestamp & sync_since + ↓ +3. Room validates password, stores sync_since + ↓ +4. Receive PUSH_CODE_LOGIN_SUCCESS (0x85) or PUSH_CODE_LOGIN_FAIL (0x86) + ↓ +5. Room auto-pushes messages (1200ms intervals) + ↓ +6. Receive PUSH_CODE_MSG_WAITING (0x83) → call CMD_SYNC_NEXT_MESSAGE (10) ``` -**Contact Types:** -- none(0): Unknown/invalid -- chat(1): Team member (shown on map) -- repeater(2): Network repeater node -- room(3): Communication channel/room +**CRITICAL**: Do NOT call `syncAllMessages()` after login. Wait for push notifications. -**Contact Path Status (`outPathLen`):** -- **-1 (0xFF)**: Path not learned yet → **Flood mode** (broadcasts to all neighbors) -- **0**: Direct connection, zero hops → **Direct mode** (best quality) -- **1+**: Multi-hop path with N hops → **Direct mode** (uses learned routing) +--- -**CRITICAL**: `outPathLen >= 0` means contact has a learned path and will use direct routing. -Only `outPathLen == -1` will use flood mode. The `hasPath` getter in `Contact` model -correctly checks `outPathLen >= 0 && outPathLen <= 64`. +## Echo Detection Feature -**Map Display:** Only `ContactType.chat` with valid GPS shown on map +### Overview +**Status**: ✅ Fully implemented and production-ready +**Purpose**: Detect when broadcast messages are rebroadcast by mesh nodes -## Service Layer +### How It Works -### LocationTrackingService (Singleton) -**Purpose:** GPS tracking + intelligent mesh network location broadcasting +1. **Packet Identification** (via `PUSH_CODE_LOG_RX_DATA` 0x88): + ``` + Packet Structure (PAYLOAD_TYPE_GRP_TXT 0x05): + [Byte 0] = Header (route + payload type + version) + [Byte 1] = Path length + [Byte 2] = Sender's node hash (first byte of public key) ✅ + [Byte 3+] = Rest of path + encrypted payload + ``` -**Key Features:** -- Callback-based architecture (onPositionUpdate, onError, onBroadcastSent, onTrackingStateChanged) -- Configurable thresholds (minDistanceMeters: 5.0m, maxDistanceMeters: 100.0m, minTimeIntervalSeconds: 30s) -- Smart broadcasting: First = immediate, ≥100m = immediate, ≥5m + ≥30s = broadcast -- Haversine distance calculation for GPS accuracy +2. **On Connection**: + - Receive `RESP_CODE_SELF_INFO` with our public key + - Extract **our node hash** (byte 0 of public key) + - Store for packet matching -**Files:** lib/services/location_tracking_service.dart (501 lines) +3. **Sending a Message**: + - Call `trackSentMessage(messageId)` when user sends channel message + - Status = "pending" (waiting for packet capture) -### MapMarkerService (Singleton) -**Purpose:** Map marker generation + geodesic calculations +4. **Packet Capture** (50-200ms later): + - Radio sends `PUSH_CODE_LOG_RX_DATA` with raw packet + - Extract sender hash from packet[2] + - If sender hash == our node hash → **This is our packet!** + - Calculate DJB2-style hash of entire packet (no crypto dependency) + - Store tracker by packet hash for echo detection -**Key Features:** -- Pure functions (testability + performance) -- Contact markers (battery badge, distance from user) -- SAR markers (color-coded: green=person, red=fire, orange=staging) -- Calculate distance, bearing/azimuth, format distance display -- Automatic "time ago" labels +5. **Echo Detection**: + - Future `PUSH_CODE_LOG_RX_DATA` packets arrive + - Calculate packet hash and lookup in tracker map (O(1)) + - If match found → **Echo detected!** Increment counter + - Track SNR/RSSI signature for path diversity + - UI auto-updates to show "Rebroadcast by X nodes" -**Files:** lib/services/map_marker_service.dart (518 lines) +### Implementation Details -### ValidationService (Singleton) -**Purpose:** Form validation + input parsing with structured error handling +**Files**: +- `lib/models/sent_message_tracker.dart` - Tracker model +- `lib/models/message.dart` - echoCount, firstEchoAt fields +- `lib/services/ble/ble_response_handler.dart` - Detection engine +- `lib/services/meshcore_ble_service.dart` - Callback wiring +- `lib/providers/connection_provider.dart` - Provider callback +- `lib/providers/messages_provider.dart` - handleMessageEcho() -**Key Features:** -- Structured result types (`ValidationResult`, `ParseResult`) -- Coordinate validation (lat: -90 to +90, lon: -180 to +180) -- Radio parameters (freq: 137-1020 MHz, bw: 7.8-500 kHz, sf: 5-12, cr: 5-8, tx: -9 to +22 dBm) -- Text/name validation, zoom level (0-19) +**Performance**: +- Packet ID: O(1) - byte comparison at offset 2 +- Hash calc: O(n) where n = packet length (~38-200 bytes) +- Echo lookup: O(1) via HashMap +- Memory: ~150 bytes/message, max 100 messages = ~15KB +- TTL: 5-minute expiry, auto-cleanup -**Files:** lib/services/validation_service.dart (511 lines) +**Limitations**: +- Echo count ≠ exact receiver count (one node can echo multiple times) +- Only detects echoes while app connected +- Network topology dependent (dense networks → more echoes) -## Map Implementation +--- -### Tile Layers -1. **OpenStreetMap** (default): Max zoom 19, street-level navigation -2. **OpenTopoMap**: Max zoom 17, topographic features -3. **ESRI World Imagery**: Max zoom 19, satellite imagery - -### Offline Tile Caching -- Backend: `flutter_map_tile_caching` with ObjectBox -- Behavior: `CacheBehavior.cacheFirst`, 30-day validity -- Region downloads: `RectangleRegion(bounds)` → `store.download.startForeground()` - -### Map Markers -**Team Member (Blue):** CircleAvatar, battery badge, name label, tap for details -**SAR Event (Color-coded):** Green (person), Red (fire), Orange (staging), time ago label, type label, tap for details - -### Map Navigation -Message tab → tap SAR marker → `MapProvider.navigateToLocation()` → switch to Map tab → `MapTab._handleMapNavigation()` → `MapProvider.clearNavigation()` - -### User Location Tracking -- Permission: `NSLocationWhenInUseUsageDescription`, `NSLocationTemporaryPreciseUsageDescription` -- Accuracy: `LocationAccuracy.best`, distance filter: 10m -- Marker: Blue pulsing circle, navigation icon, tap to center/track - -### Map Legend -Collapsible legend (top-right), shows counts of team members and SAR markers - -### Detailed Compass Dialog -Ultra-compact location display, tap to toggle DD/DMS formats, no close button (tap outside to close) - -## Common Development Tasks +## Common Tasks ### Adding a New BLE Command -1. Add command code to `lib/services/meshcore_constants.dart` -2. Add frame builder in `lib/services/protocol/frame_builder.dart` -3. Add public API method in `lib/services/meshcore_ble_service.dart` -4. Add response parser in `lib/services/protocol/frame_parser.dart` -5. Handle response in `lib/services/ble/ble_response_handler.dart` + +1. Add code to `lib/services/meshcore_constants.dart` +2. Build frame in `lib/services/protocol/frame_builder.dart` +3. Add API method in `lib/services/meshcore_ble_service.dart` +4. Parse response in `lib/services/protocol/frame_parser.dart` +5. Handle in `lib/services/ble/ble_response_handler.dart` 6. Add callback in `lib/services/meshcore_ble_service.dart` ### Adding a New SAR Marker Type + 1. Update enum in `lib/models/sar_marker.dart` -2. Add to parser in `lib/utils/sar_message_parser.dart` -3. Add color in `lib/widgets/map_markers.dart` -4. Update providers in `lib/providers/messages_provider.dart` - -### Adding a New Map Layer -1. Add to model in `lib/models/map_layer.dart` -2. Add to `allLayers` list -3. Layer appears automatically in layer selector UI - -### Working with Map Drawings -**Drawing Flow:** -1. User selects drawing mode (line/rectangle) → `DrawingProvider.setDrawingMode()` -2. User taps map → touch events captured by `DrawingLayer` -3. Preview rendered during drawing → `DrawingProvider.getPreviewDrawing()` -4. User completes drawing → saved to `DrawingProvider._drawings` list -5. User shares drawing → `DrawingToolbar._shareDrawingsToChannel()` or `_shareDrawingsToRoom()` -6. Message sent via BLE → `ConnectionProvider.sendChannelMessage()` or `sendTextMessage()` -7. Receiver parses message → `DrawingMessageParser.parseDrawingMessage()` with sender from packet -8. Drawing added to map → `DrawingProvider.addReceivedDrawing()` - -**Color Management:** -- UI uses `DrawingColors.palette` (8 Flutter Color objects) -- Network uses color indices (0-7) via `DrawingColors.colorToIndex()`/`indexToColor()` -- Persistence uses full ARGB32 color values - -**Key Files:** -- Models: `lib/models/map_drawing.dart` (278 lines) -- Parser: `lib/utils/drawing_message_parser.dart` (45 lines) -- Provider: `lib/providers/drawing_provider.dart` (280 lines) -- UI: `lib/widgets/map/drawing_toolbar.dart`, `lib/widgets/map/drawing_layer.dart` - -## Internationalization (i18n) - -### Supported Languages -- **English (en)**: Default language -- **Croatian (hr)**: Hrvatski - Full localization -- **Slovenian (sl)**: Slovenščina - Full localization - -### Localization Files -- **ARB files**: `lib/l10n/app_{locale}.arb` (Application Resource Bundle) -- **Generated class**: `lib/l10n/app_localizations.dart` (auto-generated, do not edit) -- **Configuration**: `l10n.yaml` in project root +2. Add parser logic in `lib/utils/sar_message_parser.dart` +3. Add color mapping in `lib/widgets/map_markers.dart` +4. Update handling in `lib/providers/messages_provider.dart` ### Adding Localized Strings -1. **Add to English ARB** (`lib/l10n/app_en.arb`): -```json -{ - "myNewString": "My new text", - "@myNewString": { - "description": "Description of what this string is for" - }, - "stringWithParam": "Hello {name}", - "@stringWithParam": { - "description": "Greeting with name parameter", - "placeholders": { - "name": {"type": "String"} - } - } -} -``` +1. **Add to `lib/l10n/app_en.arb`**: + ```json + { + "myNewString": "My new text", + "@myNewString": { + "description": "What this string is for" + } + } + ``` -2. **Add translations** to `app_hr.arb` and `app_sl.arb` +2. **Add translations to `app_hr.arb` and `app_sl.arb`** -3. **Generate localization files**: -```bash -flutter gen-l10n -``` +3. **Generate**: `flutter gen-l10n` 4. **Use in code**: -```dart -import '../l10n/app_localizations.dart'; + ```dart + import '../l10n/app_localizations.dart'; -// In build method: -Text(AppLocalizations.of(context)!.myNewString) -Text(AppLocalizations.of(context)!.stringWithParam('John')) + Text(AppLocalizations.of(context)!.myNewString) + ``` + +**IMPORTANT**: Always use relative import path `'../l10n/app_localizations.dart'` + +### Importing/Exporting Map Tiles + +The app supports importing and exporting cached map tiles using the `flutter_map_tile_caching` library's archive format (`.fmtc` files). + +**Export Workflow**: +1. Navigate to Map Management screen +2. Tap "Export Tiles to File" +3. Archive is created in temporary directory with gzip compression +4. System share sheet appears (iOS/Android) +5. Choose where to save: Files app, email, cloud storage, etc. +6. File named: `meshcore_tiles_.fmtc` + +**Import Workflow**: +1. Navigate to Map Management screen +2. Tap "Import Tiles from File" +3. Select `.fmtc` archive file +4. Tiles are merged with existing cache +5. Cache statistics refreshed automatically + +**API Usage**: + +```dart +// Export current cache to file +final tileCount = await tileCacheService.exportStore( + '/path/to/export.fmtc', +); + +// Import tiles from archive (with merge strategy) +final result = await tileCacheService.importStore( + '/path/to/import.fmtc', + storeNames: null, // null = import all stores + strategy: ImportConflictStrategy.merge, // default: merge +); + +// Preview stores in archive before importing +final stores = await tileCacheService.listArchiveStores( + '/path/to/archive.fmtc', +); ``` -### Important Notes -- **Import path**: Always use `import '../l10n/app_localizations.dart'` (relative path from widget location) -- **Do NOT use**: `import 'package:flutter_gen/gen_l10n/app_localizations.dart'` (incorrect) -- **Generate after changes**: Run `flutter gen-l10n` after modifying ARB files -- **Null safety**: Use `AppLocalizations.of(context)!` (with null assertion operator) +**Use Cases**: +- **Backup**: Export tiles before clearing cache or reinstalling app +- **Sharing**: Pre-download maps once, distribute to team devices +- **Disaster Recovery**: Restore offline maps after device reset +- **Bandwidth Saving**: Reduce cellular data usage by sharing cached tiles -### Localized Components -All major UI components are fully localized: -- Home screen and status indicators -- Settings screen and preferences -- Messages tab and SAR markers -- Contacts tab and contact details -- Map screen and compass dialog -- Drawing tools and filters -- All dialogs and confirmation messages +**Technical Details**: +- Archive format: `.fmtc` (FMTC native format) +- Compression: gzip (built-in by FMTC) +- Import strategy: Merge (tiles combined with existing cache) +- Export location: Temporary directory → system share sheet +- Import source: User-selected via `file_picker` package +- Conflict handling: Automatic merge of tile stores +- Cross-platform: Works on Android and iOS using native share mechanisms -## Build Commands +**File**: `lib/services/tile_cache_service.dart` - Export/import methods +**UI**: `lib/screens/map_management_screen.dart` - Import/export card + +### Map Drawing Workflow + +``` +User selects mode → DrawingProvider.setDrawingMode() + ↓ +User taps map → DrawingLayer captures touches + ↓ +Preview rendered → DrawingProvider.getPreviewDrawing() + ↓ +User completes → Saved to DrawingProvider._drawings + ↓ +User shares → DrawingToolbar._shareDrawingsToChannel/Room() + ↓ +BLE send → ConnectionProvider.sendChannelMessage/sendTextMessage() + ↓ +Receiver parses → DrawingMessageParser.parseDrawingMessage() + ↓ +Display → DrawingProvider.addReceivedDrawing() +``` + +--- + +## Build & Troubleshooting + +### Build Commands ```bash # Dependencies flutter pub get - -# Localization -flutter gen-l10n # Generate localization files after ARB changes +flutter gen-l10n # After ARB file changes # Development -flutter run # Debug mode -flutter run -d # Specific device -# Hot reload: press 'r' | Hot restart: press 'R' +flutter run # Debug mode (user controls this) +# Hot reload: save file | Hot restart: press 'R' in terminal # Code Quality -flutter analyze # Static analysis -flutter test # Run tests -dart format lib/ # Format code -flutter clean # Clean build +flutter analyze +flutter test +dart format lib/ +flutter clean -# iOS +# Release flutter build ios --release flutter build ipa - -# Android -flutter build apk --debug flutter build apk --release flutter build appbundle --release ``` -## Troubleshooting +### Common Issues -### BLE Issues -- **"Bluetooth adapter not available"**: Check Bluetooth on, verify permissions, check Info.plist/AndroidManifest.xml -- **"Connection failed"**: BLE support required, check service UUID, verify range (<10m), try scanning again +**BLE Connection Failed**: +- Check Bluetooth enabled and permissions granted +- Verify device in range (<10m) +- Check service UUID matches: `6E400001-B5A3-F393-E0A9-E50E24DCCA9E` -### Runtime Issues -**MissingPluginException**: Native plugin not installed (common after adding dependencies) +**MissingPluginException** (after adding dependencies): ```bash -cd ios && pod install && cd .. && flutter clean && flutter pub get && flutter run +cd ios && pod install && cd .. +flutter clean +flutter pub get +flutter run ``` -### Build Issues **iOS Pod Install Fails**: ```bash -cd ios && rm Podfile.lock && rm -rf Pods/ && pod install --repo-update && cd .. +cd ios +rm Podfile.lock +rm -rf Pods/ +pod install --repo-update +cd .. ``` -**CocoaPods ObjectBox Version Conflict**: -```bash -cd ios && rm Podfile.lock && rm -rf Pods/ && pod repo update && pod install && cd .. -flutter clean && flutter pub get && flutter run -``` - -**Android Gradle Timeout**: Add to `android/gradle.properties`: +**Android Gradle Timeout** - Add to `android/gradle.properties`: ``` org.gradle.daemon=true org.gradle.parallel=true org.gradle.jvmargs=-Xmx4096m ``` -**Flutter Version Conflicts**: -```bash -flutter channel stable && flutter upgrade && flutter pub upgrade -``` +### Performance Tips -## Performance Optimization - -### BLE Communication -- Buffer incoming data for partial packets -- Throttle telemetry requests (max 1/sec per contact) +**BLE**: +- Buffer partial packets +- Throttle telemetry (max 1/sec per contact) - Use `notifyListeners()` sparingly -### Map Performance -- Limit visible markers (cluster if >100) -- Use `repaint boundary` for marker widgets +**Map**: +- Cluster markers if >100 visible +- Use `RepaintBoundary` for marker widgets - Implement marker virtualization for large datasets -### Memory Management +**Memory**: - Dispose controllers in `dispose()` methods -- Clear message history after 1000 messages -- Implement tile cache size limits +- Limit message history to 1000 messages +- Set tile cache size limits -## Security Considerations -- **BLE**: No authentication in current protocol - add encryption for production -- **Permissions**: Request minimum required -- **Data**: No sensitive data logging -- **Network**: HTTPS for all tile sources +--- + +## Services Reference + +### LocationTrackingService (Singleton) +**Purpose**: GPS tracking + intelligent mesh location broadcasting + +**Callbacks**: `onPositionUpdate`, `onError`, `onBroadcastSent`, `onTrackingStateChanged` + +**Thresholds**: +- Min distance: 5.0m +- Max distance: 100.0m +- Min time: 30s + +**Logic**: +- First update → broadcast immediately +- ≥100m moved → broadcast immediately +- ≥5m + ≥30s → broadcast + +**File**: `lib/services/location_tracking_service.dart` (501 lines) + +### MapMarkerService (Singleton) +**Purpose**: Map marker generation + geodesic calculations + +**Features**: +- Pure functions (testable) +- Contact markers with battery badge, distance +- SAR markers (color-coded by type) +- Distance/bearing calculations (Haversine) +- "Time ago" formatting + +**File**: `lib/services/map_marker_service.dart` (518 lines) + +### ValidationService (Singleton) +**Purpose**: Form validation + input parsing + +**Returns**: Structured `ValidationResult` or `ParseResult` + +**Validates**: +- Coordinates (lat: -90 to +90, lon: -180 to +180) +- Radio params (freq: 137-1020 MHz, bw: 7.8-500 kHz, sf: 5-12, cr: 5-8, tx: -9 to +22 dBm) +- Text/names, zoom levels (0-19) + +**File**: `lib/services/validation_service.dart` (511 lines) + +--- + +## Map Implementation + +### Tile Layers +1. **OpenStreetMap** (default) - Max zoom 19 +2. **OpenTopoMap** - Max zoom 17, topographic +3. **ESRI World Imagery** - Max zoom 19, satellite + +### Offline Caching +- Backend: `flutter_map_tile_caching` + ObjectBox +- Behavior: `CacheBehavior.cacheFirst`, 30-day validity +- Downloads: `RectangleRegion(bounds).download.startForeground()` + +### Marker Types +**Team Member**: Blue circle, battery badge, name, distance, tap for details +**SAR Event**: Color-coded (green=person, red=fire, orange=staging), time ago, tap for details + +### Navigation Flow +``` +Messages tab → tap SAR marker + ↓ +MapProvider.navigateToLocation() + ↓ +Switch to Map tab + ↓ +MapTab._handleMapNavigation() → animate to location + ↓ +MapProvider.clearNavigation() +``` + +--- ## References + - [Flutter Documentation](https://docs.flutter.dev/) - [flutter_blue_plus API](https://pub.dev/documentation/flutter_blue_plus/) - [flutter_map Documentation](https://docs.fleaflet.dev/) - [MeshCore Protocol](https://github.com/meshcore-dev/meshcore.js) +- [MeshCore FAQ](https://github.com/meshcore-dev/MeshCore/blob/main/docs/faq.md) - [Cayenne LPP Specification](https://developers.mydevices.com/cayenne/docs/lora/#lora-cayenne-low-power-payload) - [Provider Package](https://pub.dev/packages/provider) + +--- + +## Security Considerations + +- **BLE**: No authentication in current protocol - consider encryption for production +- **Permissions**: Request minimum required permissions only +- **Logging**: No sensitive data in logs +- **Network**: HTTPS for all tile sources +- **Raw Packets**: `PUSH_CODE_LOG_RX_DATA` exposes all radio traffic (diagnostic feature) diff --git a/ios/Runner.app.dSYM.zip b/ios/Runner.app.dSYM.zip index a14fc2d..7785940 100644 Binary files a/ios/Runner.app.dSYM.zip and b/ios/Runner.app.dSYM.zip differ diff --git a/ios/Runner.ipa b/ios/Runner.ipa index a8c7d02..2e7fddc 100644 Binary files a/ios/Runner.ipa and b/ios/Runner.ipa differ diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index c7f7c0e..bdd4fc6 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -489,7 +489,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 34; + CURRENT_PROJECT_VERSION = 38; DEVELOPMENT_TEAM = JND55328G8; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -511,7 +511,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 34; + CURRENT_PROJECT_VERSION = 38; DEVELOPMENT_TEAM = JND55328G8; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; @@ -530,7 +530,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 34; + CURRENT_PROJECT_VERSION = 38; DEVELOPMENT_TEAM = JND55328G8; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; @@ -547,7 +547,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 34; + CURRENT_PROJECT_VERSION = 38; DEVELOPMENT_TEAM = JND55328G8; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; @@ -679,7 +679,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 34; + CURRENT_PROJECT_VERSION = 38; DEVELOPMENT_TEAM = JND55328G8; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -702,7 +702,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 34; + CURRENT_PROJECT_VERSION = 38; DEVELOPMENT_TEAM = JND55328G8; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 663132c..d8b1fa6 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -43,7 +43,7 @@ CFBundleSignature ???? CFBundleVersion - 34 + 38 LSRequiresIPhoneOS NSBluetoothAlwaysUsageDescription diff --git a/ios/fastlane/report.xml b/ios/fastlane/report.xml index 144ea73..2f28a8a 100644 --- a/ios/fastlane/report.xml +++ b/ios/fastlane/report.xml @@ -5,22 +5,22 @@ - + - + - + - + diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 5217cf9..425127b 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -207,6 +207,16 @@ "description": "Beschreibung für die Einstellung der RX/TX-Indikatoren" }, + "simpleMode": "Einfacher Modus", + "@simpleMode": { + "description": "Einstellung zum Aktivieren des einfachen Modus" + }, + + "simpleModeDescription": "Nicht wesentliche Informationen in Nachrichten und Kontakten ausblenden", + "@simpleModeDescription": { + "description": "Beschreibung für die Einstellung des einfachen Modus" + }, + "language": "Sprache", "@language": { "description": "Beschriftung der Spracheinstellung" @@ -514,7 +524,7 @@ "description": "Beschriftung der Aktualisieren-Schaltfläche" }, - "sendDirectMessage": "Direktnachricht senden", + "sendDirectMessage": "Senden", "@sendDirectMessage": { "description": "Aktion zum Senden einer Direktnachricht an Kontakt" }, @@ -634,6 +644,36 @@ "description": "Aktion zum Löschen aller lokalen Zeichnungen" }, + "showReceivedDrawings": "Empfangene Zeichnungen anzeigen", + "@showReceivedDrawings": { + "description": "Umschalter zum Ein-/Ausblenden empfangener Zeichnungen von anderen Teammitgliedern" + }, + + "showingAllDrawings": "Alle Zeichnungen werden angezeigt", + "@showingAllDrawings": { + "description": "Untertitel, wenn empfangene Zeichnungen sichtbar sind" + }, + + "showingOnlyYourDrawings": "Nur Ihre Zeichnungen werden angezeigt", + "@showingOnlyYourDrawings": { + "description": "Untertitel, wenn empfangene Zeichnungen ausgeblendet sind" + }, + + "showSarMarkers": "SAR-Markierungen anzeigen", + "@showSarMarkers": { + "description": "Umschalter zum Ein-/Ausblenden von SAR-Markierungen auf der Karte" + }, + + "showingSarMarkers": "SAR-Markierungen werden angezeigt", + "@showingSarMarkers": { + "description": "Untertitel, wenn SAR-Markierungen sichtbar sind" + }, + + "hidingSarMarkers": "SAR-Markierungen ausgeblendet", + "@hidingSarMarkers": { + "description": "Untertitel, wenn SAR-Markierungen ausgeblendet sind" + }, + "clearAll": "Alle löschen", "@clearAll": { "description": "Beschriftung der Alle-löschen-Schaltfläche" @@ -762,6 +802,11 @@ "description": "Beschriftung für Standortbereich" }, + "myLocation": "Mein Standort", + "@myLocation": { + "description": "Schaltflächenbeschriftung zum Einfügen der aktuellen GPS-Position" + }, + "fromMap": "Von Karte", "@fromMap": { "description": "Badge, das anzeigt, dass der Standort vom Kartentippen stammt" @@ -1125,6 +1170,21 @@ "description": "Beschriftung der GPS-Genauigkeit" }, + "distance": "Entfernung", + "@distance": { + "description": "Entfernungsbeschriftung im Kompass" + }, + + "bearing": "Peilung", + "@bearing": { + "description": "Peilungsbeschriftung im Kompass" + }, + + "direction": "Richtung", + "@direction": { + "description": "Richtungsbeschriftung im Kompass" + }, + "filterMarkers": "Markierungen filtern", "@filterMarkers": { "description": "Titel für Markierungen-filtern-Dialog" @@ -1538,6 +1598,21 @@ "description": "Name der ESRI-Satellitenbildebene" }, + "googleHybrid": "Google Hybrid", + "@googleHybrid": { + "description": "Name der Google Hybrid-Ebene (Satellit + Beschriftungen)" + }, + + "googleRoadmap": "Google Straßenkarte", + "@googleRoadmap": { + "description": "Name der Google Straßenkarten-Ebene" + }, + + "googleTerrain": "Google Gelände", + "@googleTerrain": { + "description": "Name der Google Gelände-Ebene (topografisch)" + }, + "downloadVisibleArea": "Sichtbaren Bereich herunterladen", "@downloadVisibleArea": { "description": "Tooltip für Schaltfläche zum Herunterladen des sichtbaren Bereichs" @@ -1673,6 +1748,11 @@ "description": "Infonachricht, wenn Nachricht gelöscht wird" }, + "copyText": "Text kopieren", + "textCopiedToClipboard": "Text in Zwischenablage kopiert", + "deleteMessage": "Nachricht löschen", + "deleteMessageConfirmation": "Möchten Sie diese Nachricht wirklich löschen?", + "refreshedContacts": "Kontakte aktualisiert", "@refreshedContacts": { "description": "Erfolgsmeldung, wenn Kontakte aktualisiert werden" @@ -1901,6 +1981,111 @@ "description": "Fehlermeldung, wenn MBTiles-Löschung fehlschlägt" }, + "importExportCachedTiles": "Import/Export gecachter Kacheln", + "@importExportCachedTiles": { + "description": "Titel für Import/Export-Bereich" + }, + + "importExportDescription": "Sichern, teilen und wiederherstellen Sie heruntergeladene Kartenkacheln zwischen Geräten", + "@importExportDescription": { + "description": "Beschreibung der Import/Export-Funktionalität" + }, + + "exportTilesToFile": "Kacheln in Datei exportieren", + "@exportTilesToFile": { + "description": "Schaltfläche zum Exportieren von Kacheln" + }, + + "importTilesFromFile": "Kacheln aus Datei importieren", + "@importTilesFromFile": { + "description": "Schaltfläche zum Importieren von Kacheln" + }, + + "selectExportLocation": "Exportspeicherort wählen", + "@selectExportLocation": { + "description": "Titel für Export-Dateiauswahl" + }, + + "selectImportFile": "Kachel-Archiv auswählen", + "@selectImportFile": { + "description": "Titel für Import-Dateiauswahl" + }, + + "exportingTiles": "Exportiere Kacheln...", + "@exportingTiles": { + "description": "Statusmeldung während des Exports" + }, + + "importingTiles": "Importiere Kacheln...", + "@importingTiles": { + "description": "Statusmeldung während des Imports" + }, + + "exportSuccess": "{count} Kacheln erfolgreich exportiert", + "@exportSuccess": { + "description": "Erfolgsmeldung nach Export", + "placeholders": { + "count": { + "type": "int" + } + } + }, + + "importSuccess": "{count} Speicher erfolgreich importiert", + "@importSuccess": { + "description": "Erfolgsmeldung nach Import", + "placeholders": { + "count": { + "type": "int" + } + } + }, + + "exportFailed": "Export fehlgeschlagen: {error}", + "@exportFailed": { + "description": "Fehlermeldung bei Export-Fehler", + "placeholders": { + "error": { + "type": "String" + } + } + }, + + "importFailed": "Import fehlgeschlagen: {error}", + "@importFailed": { + "description": "Fehlermeldung bei Import-Fehler", + "placeholders": { + "error": { + "type": "String" + } + } + }, + + "exportNote": "Erstellt eine komprimierte Archivdatei (.fmtc), die auf anderen Geräten geteilt und importiert werden kann.", + "@exportNote": { + "description": "Hinweis zur Export-Funktionalität" + }, + + "importNote": "Importiert Kartenkacheln aus einer zuvor exportierten Archivdatei. Kacheln werden mit dem vorhandenen Cache zusammengeführt.", + "@importNote": { + "description": "Hinweis zur Import-Funktionalität" + }, + + "noTilesToExport": "Keine Kacheln zum Exportieren verfügbar", + "@noTilesToExport": { + "description": "Meldung wenn Cache leer ist" + }, + + "archiveContainsStores": "Archiv enthält {count} Speicher", + "@archiveContainsStores": { + "description": "Information über Archivinhalte", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "vectorTiles": "Vektor-Tiles", "@vectorTiles": { "description": "Beschriftung für Vektor-Tile-Typ" @@ -2139,5 +2324,63 @@ "type": "String" } } - } + }, + + "sarTemplates": "SAR Templates", + "manageSarTemplates": "Manage cursor on target templates", + "addTemplate": "Add Template", + "editTemplate": "Edit Template", + "deleteTemplate": "Delete Template", + "templateName": "Template Name", + "templateNameHint": "e.g. Found Person", + "templateEmoji": "Emoji", + "emojiRequired": "Emoji ist erforderlich", + "nameRequired": "Name ist erforderlich", + "templateDescription": "Description (Optional)", + "templateDescriptionHint": "Add additional context...", + "templateColor": "Color", + "previewFormat": "Preview (SAR Message Format)", + "importFromClipboard": "Import", + "exportToClipboard": "Export", + "deleteTemplateConfirmation": "Delete template '{name}'?", + "templateAdded": "Template added", + "templateUpdated": "Template updated", + "templateDeleted": "Template deleted", + "templatesImported": "{count, plural, =0{No templates imported} =1{Imported 1 template} other{Imported {count} templates}}", + "templatesExported": "{count, plural, =1{Exported 1 template to clipboard} other{Exported {count} templates to clipboard}}", + "importFailed": "Import failed: {error}", + "exportFailed": "Export failed: {error}", + "resetToDefaults": "Reset to Defaults", + "resetToDefaultsConfirmation": "This will delete all custom templates and restore the 4 default templates. Continue?", + "reset": "Reset", + "resetComplete": "Templates reset to defaults", + "noTemplates": "No templates available", + "tapAddToCreate": "Tap + to create your first template", + "ok": "OK", + "delete": "Löschen", + + "permissionsSection": "Berechtigungen", + "locationPermission": "Standortberechtigung", + "checking": "Überprüfen...", + "locationPermissionGrantedAlways": "Erteilt (Immer)", + "locationPermissionGrantedWhileInUse": "Erteilt (Während der Nutzung)", + "locationPermissionDeniedTapToRequest": "Verweigert - Tippen zum Anfragen", + "locationPermissionPermanentlyDeniedOpenSettings": "Dauerhaft verweigert - Einstellungen öffnen", + "locationPermissionDialogContent": "Die Standortberechtigung wurde dauerhaft verweigert. Bitte aktivieren Sie sie in Ihren Geräteeinstellungen, um GPS-Tracking und Standortfreigabe zu nutzen.", + "openSettings": "Einstellungen öffnen", + "locationPermissionGranted": "Standortberechtigung erteilt!", + "locationPermissionRequiredForGps": "Die Standortberechtigung ist erforderlich für GPS-Tracking und Standortfreigabe.", + "locationPermissionAlreadyGranted": "Die Standortberechtigung wurde bereits erteilt.", + "sarNavyBlue": "SAR Navy Blau", + "sarNavyBlueDescription": "Professionell/Einsatzmodus", + + "selectRecipient": "Empfänger auswählen", + "broadcastToAllNearby": "An alle in der Nähe senden", + "searchRecipients": "Empfänger suchen...", + "noContactsFound": "Keine Kontakte gefunden", + "noRoomsFound": "Keine Räume gefunden", + "noContactsOrRoomsAvailable": "Keine Kontakte oder Räume verfügbar", + "messagesWillBeSentToPublicChannel": "Nachrichten werden an öffentlichen Kanal gesendet", + "newMessage": "Neue Nachricht", + "channel": "Kanal" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 409fedf..a89b50e 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -207,6 +207,16 @@ "description": "Description for RX/TX indicators setting" }, + "simpleMode": "Simple Mode", + "@simpleMode": { + "description": "Setting to enable simple mode" + }, + + "simpleModeDescription": "Hide non-essential information in messages and contacts", + "@simpleModeDescription": { + "description": "Description for simple mode setting" + }, + "language": "Language", "@language": { "description": "Language setting label" @@ -514,7 +524,7 @@ "description": "Refresh button label" }, - "sendDirectMessage": "Send Direct Message", + "sendDirectMessage": "Send", "@sendDirectMessage": { "description": "Action to send direct message to contact" }, @@ -634,6 +644,36 @@ "description": "Action to clear all local drawings" }, + "showReceivedDrawings": "Show Received Drawings", + "@showReceivedDrawings": { + "description": "Toggle to show/hide received drawings from other team members" + }, + + "showingAllDrawings": "Showing all drawings", + "@showingAllDrawings": { + "description": "Subtitle when received drawings are visible" + }, + + "showingOnlyYourDrawings": "Showing only your drawings", + "@showingOnlyYourDrawings": { + "description": "Subtitle when received drawings are hidden" + }, + + "showSarMarkers": "Show SAR Markers", + "@showSarMarkers": { + "description": "Toggle to show/hide SAR markers on map" + }, + + "showingSarMarkers": "Showing SAR markers", + "@showingSarMarkers": { + "description": "Subtitle when SAR markers are visible" + }, + + "hidingSarMarkers": "Hiding SAR markers", + "@hidingSarMarkers": { + "description": "Subtitle when SAR markers are hidden" + }, + "clearAll": "Clear All", "@clearAll": { "description": "Clear all button label" @@ -762,6 +802,11 @@ "description": "Label for location section" }, + "myLocation": "My Location", + "@myLocation": { + "description": "Button label to insert current GPS location" + }, + "fromMap": "From Map", "@fromMap": { "description": "Badge showing location is from map tap" @@ -1125,6 +1170,21 @@ "description": "GPS accuracy label" }, + "distance": "Distance", + "@distance": { + "description": "Distance label in compass" + }, + + "bearing": "Bearing", + "@bearing": { + "description": "Bearing label in compass" + }, + + "direction": "Direction", + "@direction": { + "description": "Direction label in compass" + }, + "filterMarkers": "Filter Markers", "@filterMarkers": { "description": "Title for filter markers dialog" @@ -1538,6 +1598,21 @@ "description": "ESRI Satellite imagery layer name" }, + "googleHybrid": "Google Hybrid", + "@googleHybrid": { + "description": "Google Hybrid layer name (satellite + labels)" + }, + + "googleRoadmap": "Google Roadmap", + "@googleRoadmap": { + "description": "Google Roadmap layer name (street map)" + }, + + "googleTerrain": "Google Terrain", + "@googleTerrain": { + "description": "Google Terrain layer name (topographic)" + }, + "downloadVisibleArea": "Download visible area", "@downloadVisibleArea": { "description": "Tooltip for download visible area button" @@ -1673,6 +1748,26 @@ "description": "Info message when message is deleted" }, + "copyText": "Copy text", + "@copyText": { + "description": "Option to copy message text to clipboard" + }, + + "textCopiedToClipboard": "Text copied to clipboard", + "@textCopiedToClipboard": { + "description": "Success message when text is copied" + }, + + "deleteMessage": "Delete message", + "@deleteMessage": { + "description": "Dialog title for deleting a message" + }, + + "deleteMessageConfirmation": "Are you sure you want to delete this message?", + "@deleteMessageConfirmation": { + "description": "Confirmation text for message deletion" + }, + "refreshedContacts": "Refreshed contacts", "@refreshedContacts": { "description": "Success message when contacts are refreshed" @@ -1901,6 +1996,111 @@ "description": "Error message when MBTiles deletion fails" }, + "importExportCachedTiles": "Import/Export Cached Tiles", + "@importExportCachedTiles": { + "description": "Title for import/export section" + }, + + "importExportDescription": "Backup, share, and restore downloaded map tiles between devices", + "@importExportDescription": { + "description": "Description for import/export functionality" + }, + + "exportTilesToFile": "Export Tiles to File", + "@exportTilesToFile": { + "description": "Button to export tiles to archive file" + }, + + "importTilesFromFile": "Import Tiles from File", + "@importTilesFromFile": { + "description": "Button to import tiles from archive file" + }, + + "selectExportLocation": "Select Export Location", + "@selectExportLocation": { + "description": "Title for export file picker" + }, + + "selectImportFile": "Select Tile Archive", + "@selectImportFile": { + "description": "Title for import file picker" + }, + + "exportingTiles": "Exporting tiles...", + "@exportingTiles": { + "description": "Status message during export" + }, + + "importingTiles": "Importing tiles...", + "@importingTiles": { + "description": "Status message during import" + }, + + "exportSuccess": "Exported {count} tiles successfully", + "@exportSuccess": { + "description": "Success message after export", + "placeholders": { + "count": { + "type": "int" + } + } + }, + + "importSuccess": "Imported {count} stores successfully", + "@importSuccess": { + "description": "Success message after import", + "placeholders": { + "count": { + "type": "int" + } + } + }, + + "exportFailed": "Export failed: {error}", + "@exportFailed": { + "description": "Error message when export fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + + "importFailed": "Import failed: {error}", + "@importFailed": { + "description": "Error message when import fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + + "exportNote": "Creates a compressed archive (.fmtc) file that can be shared and imported on other devices.", + "@exportNote": { + "description": "Note about export functionality" + }, + + "importNote": "Imports map tiles from a previously exported archive file. Tiles will be merged with existing cache.", + "@importNote": { + "description": "Note about import functionality" + }, + + "noTilesToExport": "No tiles available to export", + "@noTilesToExport": { + "description": "Message when cache is empty" + }, + + "archiveContainsStores": "Archive contains {count} stores", + "@archiveContainsStores": { + "description": "Information about archive contents", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "vectorTiles": "Vector Tiles", "@vectorTiles": { "description": "Label for vector tile type" @@ -2139,5 +2339,305 @@ "type": "String" } } + }, + + "sarTemplates": "SAR Templates", + "@sarTemplates": { + "description": "SAR templates menu title" + }, + + "manageSarTemplates": "Manage cursor on target templates", + "@manageSarTemplates": { + "description": "Subtitle for SAR templates settings" + }, + + "addTemplate": "Add Template", + "@addTemplate": { + "description": "Button to add new SAR template" + }, + + "editTemplate": "Edit Template", + "@editTemplate": { + "description": "Dialog title for editing template" + }, + + "deleteTemplate": "Delete Template", + "@deleteTemplate": { + "description": "Action to delete template" + }, + + "templateName": "Template Name", + "@templateName": { + "description": "Label for template name field" + }, + + "templateNameHint": "e.g. Found Person", + "@templateNameHint": { + "description": "Hint text for template name" + }, + + "templateEmoji": "Emoji", + "@templateEmoji": { + "description": "Label for template emoji field" + }, + + "emojiRequired": "Emoji is required", + "@emojiRequired": { + "description": "Validation error when emoji field is empty" + }, + + "nameRequired": "Name is required", + "@nameRequired": { + "description": "Validation error when name field is empty" + }, + + "templateDescription": "Description (Optional)", + "@templateDescription": { + "description": "Label for template description field" + }, + + "templateDescriptionHint": "Add additional context...", + "@templateDescriptionHint": { + "description": "Hint text for template description" + }, + + "templateColor": "Color", + "@templateColor": { + "description": "Label for template color picker" + }, + + "previewFormat": "Preview (SAR Message Format)", + "@previewFormat": { + "description": "Label for format preview" + }, + + "importFromClipboard": "Import", + "@importFromClipboard": { + "description": "Button to import templates from clipboard" + }, + + "exportToClipboard": "Export", + "@exportToClipboard": { + "description": "Button to export templates to clipboard" + }, + + "deleteTemplateConfirmation": "Delete template '{name}'?", + "@deleteTemplateConfirmation": { + "description": "Confirmation message for template deletion", + "placeholders": { + "name": { + "type": "String" + } + } + }, + + "templateAdded": "Template added", + "@templateAdded": { + "description": "Success message when template is added" + }, + + "templateUpdated": "Template updated", + "@templateUpdated": { + "description": "Success message when template is updated" + }, + + "templateDeleted": "Template deleted", + "@templateDeleted": { + "description": "Success message when template is deleted" + }, + + "templatesImported": "{count, plural, =0{No templates imported} =1{Imported 1 template} other{Imported {count} templates}}", + "@templatesImported": { + "description": "Success message after importing templates", + "placeholders": { + "count": { + "type": "int" + } + } + }, + + "templatesExported": "{count, plural, =1{Exported 1 template to clipboard} other{Exported {count} templates to clipboard}}", + "@templatesExported": { + "description": "Success message after exporting templates", + "placeholders": { + "count": { + "type": "int" + } + } + }, + + "importFailed": "Import failed: {error}", + "@importFailed": { + "description": "Error message when import fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + + "exportFailed": "Export failed: {error}", + "@exportFailed": { + "description": "Error message when export fails", + "placeholders": { + "error": { + "type": "String" + } + } + }, + + "resetToDefaults": "Reset to Defaults", + "@resetToDefaults": { + "description": "Action to reset templates to defaults" + }, + + "resetToDefaultsConfirmation": "This will delete all custom templates and restore the 4 default templates. Continue?", + "@resetToDefaultsConfirmation": { + "description": "Confirmation message for reset to defaults" + }, + + "reset": "Reset", + "@reset": { + "description": "Reset button label" + }, + + "resetComplete": "Templates reset to defaults", + "@resetComplete": { + "description": "Success message after reset" + }, + + "noTemplates": "No templates available", + "@noTemplates": { + "description": "Message when no templates exist" + }, + + "tapAddToCreate": "Tap + to create your first template", + "@tapAddToCreate": { + "description": "Helper text when no templates exist" + }, + + "ok": "OK", + "@ok": { + "description": "OK button label" + }, + + "delete": "Delete", + "@delete": { + "description": "Delete button label" + }, + + "permissionsSection": "Permissions", + "@permissionsSection": { + "description": "Permissions section header" + }, + + "locationPermission": "Location Permission", + "@locationPermission": { + "description": "Location permission label" + }, + + "checking": "Checking...", + "@checking": { + "description": "Loading state indicator" + }, + + "locationPermissionGrantedAlways": "Granted (Always)", + "@locationPermissionGrantedAlways": { + "description": "Location permission status: granted always" + }, + + "locationPermissionGrantedWhileInUse": "Granted (While In Use)", + "@locationPermissionGrantedWhileInUse": { + "description": "Location permission status: granted while in use" + }, + + "locationPermissionDeniedTapToRequest": "Denied - Tap to request", + "@locationPermissionDeniedTapToRequest": { + "description": "Location permission status: denied, user can request" + }, + + "locationPermissionPermanentlyDeniedOpenSettings": "Permanently Denied - Open Settings", + "@locationPermissionPermanentlyDeniedOpenSettings": { + "description": "Location permission status: permanently denied" + }, + + "locationPermissionDialogContent": "Location permission is permanently denied. Please enable it in your device settings to use GPS tracking and location sharing features.", + "@locationPermissionDialogContent": { + "description": "Content for location permission dialog when permanently denied" + }, + + "openSettings": "Open Settings", + "@openSettings": { + "description": "Button to open device settings" + }, + + "locationPermissionGranted": "Location permission granted!", + "@locationPermissionGranted": { + "description": "Success message when location permission is granted" + }, + + "locationPermissionRequiredForGps": "Location permission is required for GPS tracking and location sharing.", + "@locationPermissionRequiredForGps": { + "description": "Info message about location permission requirement" + }, + + "locationPermissionAlreadyGranted": "Location permission is already granted.", + "@locationPermissionAlreadyGranted": { + "description": "Info message when permission is already granted" + }, + + "sarNavyBlue": "SAR Navy Blue", + "@sarNavyBlue": { + "description": "SAR Navy Blue theme name" + }, + + "sarNavyBlueDescription": "Professional/Operations Mode", + "@sarNavyBlueDescription": { + "description": "Description for SAR Navy Blue theme" + }, + + "selectRecipient": "Select Recipient", + "@selectRecipient": { + "description": "Title for recipient selector sheet" + }, + + "broadcastToAllNearby": "Broadcast to all nearby", + "@broadcastToAllNearby": { + "description": "Subtitle for public channel option" + }, + + "searchRecipients": "Search recipients...", + "@searchRecipients": { + "description": "Placeholder text for recipient search field" + }, + + "noContactsFound": "No contacts found", + "@noContactsFound": { + "description": "Message when no contacts match search" + }, + + "noRoomsFound": "No rooms found", + "@noRoomsFound": { + "description": "Message when no rooms match search" + }, + + "noContactsOrRoomsAvailable": "No contacts or rooms available", + "@noContactsOrRoomsAvailable": { + "description": "Message when no contacts or rooms exist" + }, + + "messagesWillBeSentToPublicChannel": "Messages will be sent to public channel", + "@messagesWillBeSentToPublicChannel": { + "description": "Info message when only public channel is available" + }, + + "newMessage": "New message", + "@newMessage": { + "description": "Notification title for new message" + }, + + "channel": "Channel", + "@channel": { + "description": "Channel label in notifications" } } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index b8173c2..64fb259 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -207,6 +207,16 @@ "description": "Descripción de la configuración de indicadores RX/TX" }, + "simpleMode": "Modo Simple", + "@simpleMode": { + "description": "Configuración para habilitar el modo simple" + }, + + "simpleModeDescription": "Ocultar información no esencial en mensajes y contactos", + "@simpleModeDescription": { + "description": "Descripción de la configuración del modo simple" + }, + "language": "Idioma", "@language": { "description": "Etiqueta de la configuración de idioma" @@ -514,7 +524,7 @@ "description": "Etiqueta del botón de actualizar" }, - "sendDirectMessage": "Enviar mensaje directo", + "sendDirectMessage": "Enviar", "@sendDirectMessage": { "description": "Acción para enviar mensaje directo al contacto" }, @@ -634,6 +644,36 @@ "description": "Acción para borrar todos los dibujos locales" }, + "showReceivedDrawings": "Mostrar dibujos recibidos", + "@showReceivedDrawings": { + "description": "Alternar para mostrar/ocultar dibujos recibidos de otros miembros del equipo" + }, + + "showingAllDrawings": "Mostrando todos los dibujos", + "@showingAllDrawings": { + "description": "Subtítulo cuando los dibujos recibidos son visibles" + }, + + "showingOnlyYourDrawings": "Mostrando solo tus dibujos", + "@showingOnlyYourDrawings": { + "description": "Subtítulo cuando los dibujos recibidos están ocultos" + }, + + "showSarMarkers": "Mostrar marcadores SAR", + "@showSarMarkers": { + "description": "Alternar para mostrar/ocultar marcadores SAR en el mapa" + }, + + "showingSarMarkers": "Mostrando marcadores SAR", + "@showingSarMarkers": { + "description": "Subtítulo cuando los marcadores SAR son visibles" + }, + + "hidingSarMarkers": "Ocultando marcadores SAR", + "@hidingSarMarkers": { + "description": "Subtítulo cuando los marcadores SAR están ocultos" + }, + "clearAll": "Borrar todo", "@clearAll": { "description": "Etiqueta del botón de borrar todo" @@ -762,6 +802,11 @@ "description": "Etiqueta para la sección de ubicación" }, + "myLocation": "Mi ubicación", + "@myLocation": { + "description": "Etiqueta del botón para insertar la ubicación GPS actual" + }, + "fromMap": "Desde el mapa", "@fromMap": { "description": "Insignia que muestra que la ubicación es desde un toque en el mapa" @@ -1125,6 +1170,21 @@ "description": "Etiqueta de precisión GPS" }, + "distance": "Distancia", + "@distance": { + "description": "Etiqueta de distancia en la brújula" + }, + + "bearing": "Rumbo", + "@bearing": { + "description": "Etiqueta de rumbo en la brújula" + }, + + "direction": "Dirección", + "@direction": { + "description": "Etiqueta de dirección en la brújula" + }, + "filterMarkers": "Filtrar marcadores", "@filterMarkers": { "description": "Título del diálogo de filtrar marcadores" @@ -1538,6 +1598,21 @@ "description": "Nombre de capa de imágenes de satélite ESRI" }, + "googleHybrid": "Google Híbrido", + "@googleHybrid": { + "description": "Nombre de capa Google Híbrido (satélite + etiquetas)" + }, + + "googleRoadmap": "Google Mapa de Carreteras", + "@googleRoadmap": { + "description": "Nombre de capa Google Mapa de Carreteras" + }, + + "googleTerrain": "Google Terreno", + "@googleTerrain": { + "description": "Nombre de capa Google Terreno (topográfico)" + }, + "downloadVisibleArea": "Descargar área visible", "@downloadVisibleArea": { "description": "Tooltip para el botón de descargar área visible" @@ -1673,6 +1748,11 @@ "description": "Mensaje de información cuando se elimina mensaje" }, + "copyText": "Copiar texto", + "textCopiedToClipboard": "Texto copiado al portapapeles", + "deleteMessage": "Eliminar mensaje", + "deleteMessageConfirmation": "¿Está seguro de que desea eliminar este mensaje?", + "refreshedContacts": "Contactos actualizados", "@refreshedContacts": { "description": "Mensaje de éxito cuando se actualizan contactos" @@ -1901,6 +1981,111 @@ "description": "Mensaje de error cuando falla la eliminación de MBTiles" }, + "importExportCachedTiles": "Importar/Exportar teselas en caché", + "@importExportCachedTiles": { + "description": "Título para sección de importar/exportar" + }, + + "importExportDescription": "Realice copias de seguridad, comparta y restaure teselas de mapas descargadas entre dispositivos", + "@importExportDescription": { + "description": "Descripción de funcionalidad de importar/exportar" + }, + + "exportTilesToFile": "Exportar teselas a archivo", + "@exportTilesToFile": { + "description": "Botón para exportar teselas" + }, + + "importTilesFromFile": "Importar teselas desde archivo", + "@importTilesFromFile": { + "description": "Botón para importar teselas" + }, + + "selectExportLocation": "Seleccionar ubicación de exportación", + "@selectExportLocation": { + "description": "Título para selector de archivo de exportación" + }, + + "selectImportFile": "Seleccionar archivo de teselas", + "@selectImportFile": { + "description": "Título para selector de archivo de importación" + }, + + "exportingTiles": "Exportando teselas...", + "@exportingTiles": { + "description": "Mensaje de estado durante exportación" + }, + + "importingTiles": "Importando teselas...", + "@importingTiles": { + "description": "Mensaje de estado durante importación" + }, + + "exportSuccess": "{count} teselas exportadas exitosamente", + "@exportSuccess": { + "description": "Mensaje de éxito después de exportar", + "placeholders": { + "count": { + "type": "int" + } + } + }, + + "importSuccess": "{count} almacenes importados exitosamente", + "@importSuccess": { + "description": "Mensaje de éxito después de importar", + "placeholders": { + "count": { + "type": "int" + } + } + }, + + "exportFailed": "Error en exportación: {error}", + "@exportFailed": { + "description": "Mensaje de error cuando falla exportación", + "placeholders": { + "error": { + "type": "String" + } + } + }, + + "importFailed": "Error en importación: {error}", + "@importFailed": { + "description": "Mensaje de error cuando falla importación", + "placeholders": { + "error": { + "type": "String" + } + } + }, + + "exportNote": "Crea un archivo comprimido (.fmtc) que se puede compartir e importar en otros dispositivos.", + "@exportNote": { + "description": "Nota sobre funcionalidad de exportación" + }, + + "importNote": "Importa teselas de mapa desde un archivo previamente exportado. Las teselas se fusionarán con la caché existente.", + "@importNote": { + "description": "Nota sobre funcionalidad de importación" + }, + + "noTilesToExport": "No hay teselas para exportar", + "@noTilesToExport": { + "description": "Mensaje cuando caché está vacío" + }, + + "archiveContainsStores": "El archivo contiene {count} almacenes", + "@archiveContainsStores": { + "description": "Información sobre contenido del archivo", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "vectorTiles": "Teselas vectoriales", "@vectorTiles": { "description": "Etiqueta del tipo de tesela vectorial" @@ -2134,5 +2319,63 @@ "type": "String" } } - } + }, + + "sarTemplates": "SAR Templates", + "manageSarTemplates": "Manage cursor on target templates", + "addTemplate": "Add Template", + "editTemplate": "Edit Template", + "deleteTemplate": "Delete Template", + "templateName": "Template Name", + "templateNameHint": "e.g. Found Person", + "templateEmoji": "Emoji", + "emojiRequired": "Se requiere emoji", + "nameRequired": "Se requiere nombre", + "templateDescription": "Description (Optional)", + "templateDescriptionHint": "Add additional context...", + "templateColor": "Color", + "previewFormat": "Preview (SAR Message Format)", + "importFromClipboard": "Import", + "exportToClipboard": "Export", + "deleteTemplateConfirmation": "Delete template '{name}'?", + "templateAdded": "Template added", + "templateUpdated": "Template updated", + "templateDeleted": "Template deleted", + "templatesImported": "{count, plural, =0{No templates imported} =1{Imported 1 template} other{Imported {count} templates}}", + "templatesExported": "{count, plural, =1{Exported 1 template to clipboard} other{Exported {count} templates to clipboard}}", + "importFailed": "Import failed: {error}", + "exportFailed": "Export failed: {error}", + "resetToDefaults": "Reset to Defaults", + "resetToDefaultsConfirmation": "This will delete all custom templates and restore the 4 default templates. Continue?", + "reset": "Reset", + "resetComplete": "Templates reset to defaults", + "noTemplates": "No templates available", + "tapAddToCreate": "Tap + to create your first template", + "ok": "OK", + "delete": "Eliminar", + + "permissionsSection": "Permisos", + "locationPermission": "Permiso de ubicación", + "checking": "Comprobando...", + "locationPermissionGrantedAlways": "Concedido (Siempre)", + "locationPermissionGrantedWhileInUse": "Concedido (Durante el uso)", + "locationPermissionDeniedTapToRequest": "Denegado - Toca para solicitar", + "locationPermissionPermanentlyDeniedOpenSettings": "Denegado permanentemente - Abrir ajustes", + "locationPermissionDialogContent": "El permiso de ubicación está permanentemente denegado. Por favor, actívalo en la configuración de tu dispositivo para usar el rastreo GPS y compartir ubicación.", + "openSettings": "Abrir ajustes", + "locationPermissionGranted": "¡Permiso de ubicación concedido!", + "locationPermissionRequiredForGps": "El permiso de ubicación es necesario para el rastreo GPS y compartir ubicación.", + "locationPermissionAlreadyGranted": "El permiso de ubicación ya está concedido.", + "sarNavyBlue": "SAR Azul Marino", + "sarNavyBlueDescription": "Modo Profesional/Operaciones", + + "selectRecipient": "Seleccionar destinatario", + "broadcastToAllNearby": "Transmitir a todos cercanos", + "searchRecipients": "Buscar destinatarios...", + "noContactsFound": "No se encontraron contactos", + "noRoomsFound": "No se encontraron salas", + "noContactsOrRoomsAvailable": "No hay contactos o salas disponibles", + "messagesWillBeSentToPublicChannel": "Los mensajes se enviarán al canal público", + "newMessage": "Nuevo mensaje", + "channel": "Canal" } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 62e8ece..2df4a18 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -207,6 +207,16 @@ "description": "Description du paramètre des indicateurs RX/TX" }, + "simpleMode": "Mode Simple", + "@simpleMode": { + "description": "Paramètre pour activer le mode simple" + }, + + "simpleModeDescription": "Masquer les informations non essentielles dans les messages et les contacts", + "@simpleModeDescription": { + "description": "Description du paramètre du mode simple" + }, + "language": "Langue", "@language": { "description": "Libellé du paramètre de langue" @@ -514,7 +524,7 @@ "description": "Libellé du bouton Actualiser" }, - "sendDirectMessage": "Envoyer un message direct", + "sendDirectMessage": "Envoyer", "@sendDirectMessage": { "description": "Action pour envoyer un message direct au contact" }, @@ -634,6 +644,36 @@ "description": "Action pour effacer tous les dessins locaux" }, + "showReceivedDrawings": "Afficher les dessins reçus", + "@showReceivedDrawings": { + "description": "Basculer pour afficher/masquer les dessins reçus des autres membres de l'équipe" + }, + + "showingAllDrawings": "Affichage de tous les dessins", + "@showingAllDrawings": { + "description": "Sous-titre lorsque les dessins reçus sont visibles" + }, + + "showingOnlyYourDrawings": "Affichage uniquement de vos dessins", + "@showingOnlyYourDrawings": { + "description": "Sous-titre lorsque les dessins reçus sont masqués" + }, + + "showSarMarkers": "Afficher les marqueurs SAR", + "@showSarMarkers": { + "description": "Basculer pour afficher/masquer les marqueurs SAR sur la carte" + }, + + "showingSarMarkers": "Affichage des marqueurs SAR", + "@showingSarMarkers": { + "description": "Sous-titre lorsque les marqueurs SAR sont visibles" + }, + + "hidingSarMarkers": "Masquage des marqueurs SAR", + "@hidingSarMarkers": { + "description": "Sous-titre lorsque les marqueurs SAR sont masqués" + }, + "clearAll": "Tout effacer", "@clearAll": { "description": "Libellé du bouton Tout effacer" @@ -762,6 +802,11 @@ "description": "Libellé de la section de position" }, + "myLocation": "Ma position", + "@myLocation": { + "description": "Libellé du bouton pour insérer la position GPS actuelle" + }, + "fromMap": "Depuis la carte", "@fromMap": { "description": "Badge indiquant que la position provient d'un clic sur la carte" @@ -1125,6 +1170,21 @@ "description": "Libellé de la précision GPS" }, + "distance": "Distance", + "@distance": { + "description": "Libellé de la distance dans la boussole" + }, + + "bearing": "Relèvement", + "@bearing": { + "description": "Libellé du relèvement dans la boussole" + }, + + "direction": "Direction", + "@direction": { + "description": "Libellé de la direction dans la boussole" + }, + "filterMarkers": "Filtrer les marqueurs", "@filterMarkers": { "description": "Titre de la boîte de dialogue de filtrage des marqueurs" @@ -1538,6 +1598,21 @@ "description": "Nom de la couche d'imagerie satellite ESRI" }, + "googleHybrid": "Google Hybride", + "@googleHybrid": { + "description": "Nom de la couche Google Hybride (satellite + étiquettes)" + }, + + "googleRoadmap": "Google Carte Routière", + "@googleRoadmap": { + "description": "Nom de la couche Google Carte Routière" + }, + + "googleTerrain": "Google Terrain", + "@googleTerrain": { + "description": "Nom de la couche Google Terrain (topographique)" + }, + "downloadVisibleArea": "Télécharger la zone visible", "@downloadVisibleArea": { "description": "Info-bulle du bouton de téléchargement de la zone visible" @@ -1673,6 +1748,11 @@ "description": "Message d'information lorsque le message est supprimé" }, + "copyText": "Copier le texte", + "textCopiedToClipboard": "Texte copié dans le presse-papiers", + "deleteMessage": "Supprimer le message", + "deleteMessageConfirmation": "Êtes-vous sûr de vouloir supprimer ce message?", + "refreshedContacts": "Contacts actualisés", "@refreshedContacts": { "description": "Message de succès lorsque les contacts sont actualisés" @@ -1901,6 +1981,111 @@ "description": "Message d'erreur lorsque la suppression de MBTiles échoue" }, + "importExportCachedTiles": "Importer/Exporter les tuiles en cache", + "@importExportCachedTiles": { + "description": "Titre pour section d'importation/exportation" + }, + + "importExportDescription": "Sauvegarder, partager et restaurer les tuiles de carte téléchargées entre appareils", + "@importExportDescription": { + "description": "Description de la fonctionnalité d'importation/exportation" + }, + + "exportTilesToFile": "Exporter les tuiles vers fichier", + "@exportTilesToFile": { + "description": "Bouton pour exporter les tuiles" + }, + + "importTilesFromFile": "Importer les tuiles depuis fichier", + "@importTilesFromFile": { + "description": "Bouton pour importer les tuiles" + }, + + "selectExportLocation": "Sélectionner l'emplacement d'exportation", + "@selectExportLocation": { + "description": "Titre pour sélecteur de fichier d'exportation" + }, + + "selectImportFile": "Sélectionner l'archive de tuiles", + "@selectImportFile": { + "description": "Titre pour sélecteur de fichier d'importation" + }, + + "exportingTiles": "Exportation des tuiles...", + "@exportingTiles": { + "description": "Message de statut pendant l'exportation" + }, + + "importingTiles": "Importation des tuiles...", + "@importingTiles": { + "description": "Message de statut pendant l'importation" + }, + + "exportSuccess": "{count} tuiles exportées avec succès", + "@exportSuccess": { + "description": "Message de succès après exportation", + "placeholders": { + "count": { + "type": "int" + } + } + }, + + "importSuccess": "{count} magasins importés avec succès", + "@importSuccess": { + "description": "Message de succès après importation", + "placeholders": { + "count": { + "type": "int" + } + } + }, + + "exportFailed": "Échec de l'exportation: {error}", + "@exportFailed": { + "description": "Message d'erreur lors de l'échec de l'exportation", + "placeholders": { + "error": { + "type": "String" + } + } + }, + + "importFailed": "Échec de l'importation: {error}", + "@importFailed": { + "description": "Message d'erreur lors de l'échec de l'importation", + "placeholders": { + "error": { + "type": "String" + } + } + }, + + "exportNote": "Crée un fichier d'archive compressé (.fmtc) qui peut être partagé et importé sur d'autres appareils.", + "@exportNote": { + "description": "Note sur la fonctionnalité d'exportation" + }, + + "importNote": "Importe les tuiles de carte depuis un fichier d'archive précédemment exporté. Les tuiles seront fusionnées avec le cache existant.", + "@importNote": { + "description": "Note sur la fonctionnalité d'importation" + }, + + "noTilesToExport": "Aucune tuile à exporter", + "@noTilesToExport": { + "description": "Message quand le cache est vide" + }, + + "archiveContainsStores": "L'archive contient {count} magasins", + "@archiveContainsStores": { + "description": "Information sur le contenu de l'archive", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "vectorTiles": "Tuiles vectorielles", "@vectorTiles": { "description": "Libellé du type de tuile vectorielle" @@ -2139,5 +2324,63 @@ "type": "String" } } - } + }, + + "sarTemplates": "SAR Templates", + "manageSarTemplates": "Manage cursor on target templates", + "addTemplate": "Add Template", + "editTemplate": "Edit Template", + "deleteTemplate": "Delete Template", + "templateName": "Template Name", + "templateNameHint": "e.g. Found Person", + "templateEmoji": "Emoji", + "emojiRequired": "Emoji est requis", + "nameRequired": "Nom est requis", + "templateDescription": "Description (Optional)", + "templateDescriptionHint": "Add additional context...", + "templateColor": "Color", + "previewFormat": "Preview (SAR Message Format)", + "importFromClipboard": "Import", + "exportToClipboard": "Export", + "deleteTemplateConfirmation": "Delete template '{name}'?", + "templateAdded": "Template added", + "templateUpdated": "Template updated", + "templateDeleted": "Template deleted", + "templatesImported": "{count, plural, =0{No templates imported} =1{Imported 1 template} other{Imported {count} templates}}", + "templatesExported": "{count, plural, =1{Exported 1 template to clipboard} other{Exported {count} templates to clipboard}}", + "importFailed": "Import failed: {error}", + "exportFailed": "Export failed: {error}", + "resetToDefaults": "Reset to Defaults", + "resetToDefaultsConfirmation": "This will delete all custom templates and restore the 4 default templates. Continue?", + "reset": "Reset", + "resetComplete": "Templates reset to defaults", + "noTemplates": "No templates available", + "tapAddToCreate": "Tap + to create your first template", + "ok": "OK", + "delete": "Supprimer", + + "permissionsSection": "Autorisations", + "locationPermission": "Autorisation de localisation", + "checking": "Vérification...", + "locationPermissionGrantedAlways": "Accordée (Toujours)", + "locationPermissionGrantedWhileInUse": "Accordée (En cours d'utilisation)", + "locationPermissionDeniedTapToRequest": "Refusée - Appuyez pour demander", + "locationPermissionPermanentlyDeniedOpenSettings": "Refusée définitivement - Ouvrir les paramètres", + "locationPermissionDialogContent": "L'autorisation de localisation est définitivement refusée. Veuillez l'activer dans les paramètres de votre appareil pour utiliser le suivi GPS et le partage de localisation.", + "openSettings": "Ouvrir les paramètres", + "locationPermissionGranted": "Autorisation de localisation accordée !", + "locationPermissionRequiredForGps": "L'autorisation de localisation est nécessaire pour le suivi GPS et le partage de localisation.", + "locationPermissionAlreadyGranted": "L'autorisation de localisation est déjà accordée.", + "sarNavyBlue": "SAR Bleu Marine", + "sarNavyBlueDescription": "Mode Professionnel/Opérations", + + "selectRecipient": "Sélectionner le destinataire", + "broadcastToAllNearby": "Diffuser à tous à proximité", + "searchRecipients": "Rechercher des destinataires...", + "noContactsFound": "Aucun contact trouvé", + "noRoomsFound": "Aucune salle trouvée", + "noContactsOrRoomsAvailable": "Aucun contact ou salle disponible", + "messagesWillBeSentToPublicChannel": "Les messages seront envoyés au canal public", + "newMessage": "Nouveau message", + "channel": "Canal" } diff --git a/lib/l10n/app_hr.arb b/lib/l10n/app_hr.arb index 0750029..a149a69 100644 --- a/lib/l10n/app_hr.arb +++ b/lib/l10n/app_hr.arb @@ -75,6 +75,10 @@ "displayPacketActivity": "Prikaži indikatore aktivnosti paketa u gornjoj traci", + "simpleMode": "Jednostavni način", + + "simpleModeDescription": "Sakrij nevažne informacije u porukama i kontaktima", + "language": "Jezik", "chooseLanguage": "Odaberite jezik", @@ -181,7 +185,7 @@ "refresh": "Osvježi", - "sendDirectMessage": "Pošalji izravnu poruku", + "sendDirectMessage": "Pošalji", "resetPath": "Resetiraj put (preusmjeri)", @@ -221,6 +225,18 @@ "clearAllDrawings": "Očisti sve crteže", + "showReceivedDrawings": "Prikaži primljene crteže", + + "showingAllDrawings": "Prikazujem sve crteže", + + "showingOnlyYourDrawings": "Prikazujem samo vaše crteže", + + "showSarMarkers": "Prikaži SAR oznake", + + "showingSarMarkers": "Prikazujem SAR oznake", + + "hidingSarMarkers": "Skrivam SAR oznake", + "clearAll": "Očisti sve", "noLocalDrawings": "Nema lokalnih crteža za dijeljenje", @@ -263,6 +279,8 @@ "location": "Lokacija", + "myLocation": "Moja lokacija", + "fromMap": "S karte", "gettingLocation": "Dohvaćanje lokacije...", @@ -379,6 +397,12 @@ "accuracy": "Točnost", + "distance": "Udaljenost", + + "bearing": "Azimut", + + "direction": "Smjer", + "filterMarkers": "Filtriraj markere", "filterMarkersTooltip": "Filtriraj markere", @@ -521,6 +545,12 @@ "esriSatellite": "ESRI satelit", + "googleHybrid": "Google hibridno", + + "googleRoadmap": "Google cestovna karta", + + "googleTerrain": "Google teren", + "downloadVisibleArea": "Preuzmi vidljivo područje", "initializingMap": "Inicijalizacija karte...", @@ -586,6 +616,10 @@ "cannotReplyContactNotFound": "Ne mogu odgovoriti: kontakt nije pronađen", "messageDeleted": "Poruka izbrisana", + "copyText": "Kopiraj tekst", + "textCopiedToClipboard": "Tekst kopiran u međuspremnik", + "deleteMessage": "Izbriši poruku", + "deleteMessageConfirmation": "Jeste li sigurni da želite izbrisati ovu poruku?", "refreshedContacts": "Kontakti osvježeni", @@ -636,6 +670,38 @@ "failedToDeleteMbtiles": "Neuspjelo brisanje offline karte", + "importExportCachedTiles": "Uvoz/Izvoz predmemoriranih pločica", + + "importExportDescription": "Sigurnosno kopirajte, dijelite i vraćajte preuzete pločice karte između uređaja", + + "exportTilesToFile": "Izvezi pločice u datoteku", + + "importTilesFromFile": "Uvezi pločice iz datoteke", + + "selectExportLocation": "Odaberite lokaciju izvoza", + + "selectImportFile": "Odaberite arhivu pločica", + + "exportingTiles": "Izvažanje pločica...", + + "importingTiles": "Uvažanje pločica...", + + "exportSuccess": "Uspješno izvezeno {count} pločica", + + "importSuccess": "Uspješno uvezeno {count} skladišta", + + "exportFailed": "Izvoz nije uspio: {error}", + + "importFailed": "Uvoz nije uspio: {error}", + + "exportNote": "Stvara komprimiranu arhivsku datoteku (.fmtc) koju možete dijeliti i uvesti na drugim uređajima.", + + "importNote": "Uvozi pločice karte iz prethodno izvezene arhivske datoteke. Pločice će biti spojene s postojećom predmemorijom.", + + "noTilesToExport": "Nema pločica za izvoz", + + "archiveContainsStores": "Arhiva sadrži {count} skladišta", + "vectorTiles": "Vektorske pločice", "schema": "Shema", @@ -722,5 +788,63 @@ "failedToSave": "Neuspjelo spremanje: {error}", - "failedToGetLocation": "Neuspjelo dohvaćanje lokacije: {error}" + "failedToGetLocation": "Neuspjelo dohvaćanje lokacije: {error}", + + "sarTemplates": "SAR Templates", + "manageSarTemplates": "Manage cursor on target templates", + "addTemplate": "Add Template", + "editTemplate": "Edit Template", + "deleteTemplate": "Delete Template", + "templateName": "Template Name", + "templateNameHint": "e.g. Found Person", + "templateEmoji": "Emoji", + "emojiRequired": "Emoji je obavezan", + "nameRequired": "Ime je obavezno", + "templateDescription": "Description (Optional)", + "templateDescriptionHint": "Add additional context...", + "templateColor": "Color", + "previewFormat": "Preview (SAR Message Format)", + "importFromClipboard": "Import", + "exportToClipboard": "Export", + "deleteTemplateConfirmation": "Delete template '{name}'?", + "templateAdded": "Template added", + "templateUpdated": "Template updated", + "templateDeleted": "Template deleted", + "templatesImported": "{count, plural, =0{No templates imported} =1{Imported 1 template} other{Imported {count} templates}}", + "templatesExported": "{count, plural, =1{Exported 1 template to clipboard} other{Exported {count} templates to clipboard}}", + "importFailed": "Import failed: {error}", + "exportFailed": "Export failed: {error}", + "resetToDefaults": "Reset to Defaults", + "resetToDefaultsConfirmation": "This will delete all custom templates and restore the 4 default templates. Continue?", + "reset": "Reset", + "resetComplete": "Templates reset to defaults", + "noTemplates": "No templates available", + "tapAddToCreate": "Tap + to create your first template", + "ok": "OK", + "delete": "Obriši", + + "permissionsSection": "Dozvole", + "locationPermission": "Dozvola za lokaciju", + "checking": "Provjera...", + "locationPermissionGrantedAlways": "Odobreno (Uvijek)", + "locationPermissionGrantedWhileInUse": "Odobreno (Tijekom uporabe)", + "locationPermissionDeniedTapToRequest": "Odbijeno - Dodirnite za zahtjev", + "locationPermissionPermanentlyDeniedOpenSettings": "Trajno odbijeno - Otvori postavke", + "locationPermissionDialogContent": "Dozvola za lokaciju je trajno odbijena. Omogućite je u postavkama uređaja kako biste koristili GPS praćenje i dijeljenje lokacije.", + "openSettings": "Otvori postavke", + "locationPermissionGranted": "Dozvola za lokaciju odobrena!", + "locationPermissionRequiredForGps": "Dozvola za lokaciju je potrebna za GPS praćenje i dijeljenje lokacije.", + "locationPermissionAlreadyGranted": "Dozvola za lokaciju je već odobrena.", + "sarNavyBlue": "SAR Mornarsko Plava", + "sarNavyBlueDescription": "Profesionalni/Operativni Način", + + "selectRecipient": "Odaberi primatelja", + "broadcastToAllNearby": "Emituj svima u blizini", + "searchRecipients": "Pretraži primatelje...", + "noContactsFound": "Nema kontakata", + "noRoomsFound": "Nema soba", + "noContactsOrRoomsAvailable": "Nema dostupnih kontakata ili soba", + "messagesWillBeSentToPublicChannel": "Poruke će biti poslane na javni kanal", + "newMessage": "Nova poruka", + "channel": "Kanal" } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 8e6ff4b..a59caa8 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -207,6 +207,16 @@ "description": "Descrizione per l'impostazione degli indicatori RX/TX" }, + "simpleMode": "Modalità Semplice", + "@simpleMode": { + "description": "Impostazione per abilitare la modalità semplice" + }, + + "simpleModeDescription": "Nascondi informazioni non essenziali nei messaggi e contatti", + "@simpleModeDescription": { + "description": "Descrizione per l'impostazione della modalità semplice" + }, + "language": "Lingua", "@language": { "description": "Etichetta impostazione lingua" @@ -514,7 +524,7 @@ "description": "Etichetta pulsante Aggiorna" }, - "sendDirectMessage": "Invia Messaggio Diretto", + "sendDirectMessage": "Invia", "@sendDirectMessage": { "description": "Azione per inviare messaggio diretto al contatto" }, @@ -634,6 +644,36 @@ "description": "Azione per cancellare tutti i disegni locali" }, + "showReceivedDrawings": "Mostra Disegni Ricevuti", + "@showReceivedDrawings": { + "description": "Interruttore per mostrare/nascondere i disegni ricevuti da altri membri del team" + }, + + "showingAllDrawings": "Visualizzazione di tutti i disegni", + "@showingAllDrawings": { + "description": "Sottotitolo quando i disegni ricevuti sono visibili" + }, + + "showingOnlyYourDrawings": "Visualizzazione solo dei tuoi disegni", + "@showingOnlyYourDrawings": { + "description": "Sottotitolo quando i disegni ricevuti sono nascosti" + }, + + "showSarMarkers": "Mostra marcatori SAR", + "@showSarMarkers": { + "description": "Interruttore per mostrare/nascondere i marcatori SAR sulla mappa" + }, + + "showingSarMarkers": "Visualizzazione marcatori SAR", + "@showingSarMarkers": { + "description": "Sottotitolo quando i marcatori SAR sono visibili" + }, + + "hidingSarMarkers": "Nascondere marcatori SAR", + "@hidingSarMarkers": { + "description": "Sottotitolo quando i marcatori SAR sono nascosti" + }, + "clearAll": "Cancella Tutto", "@clearAll": { "description": "Etichetta pulsante Cancella Tutto" @@ -762,6 +802,11 @@ "description": "Etichetta per la sezione posizione" }, + "myLocation": "La mia posizione", + "@myLocation": { + "description": "Etichetta del pulsante per inserire la posizione GPS attuale" + }, + "fromMap": "Dalla Mappa", "@fromMap": { "description": "Badge che mostra che la posizione proviene dal tocco sulla mappa" @@ -1125,6 +1170,21 @@ "description": "Etichetta precisione GPS" }, + "distance": "Distanza", + "@distance": { + "description": "Etichetta distanza nella bussola" + }, + + "bearing": "Rilevamento", + "@bearing": { + "description": "Etichetta rilevamento nella bussola" + }, + + "direction": "Direzione", + "@direction": { + "description": "Etichetta direzione nella bussola" + }, + "filterMarkers": "Filtra Marcatori", "@filterMarkers": { "description": "Titolo per la finestra filtra marcatori" @@ -1538,6 +1598,21 @@ "description": "Nome livello immagini satellitari ESRI" }, + "googleHybrid": "Google Ibrido", + "@googleHybrid": { + "description": "Nome livello Google Ibrido (satellite + etichette)" + }, + + "googleRoadmap": "Google Mappa Stradale", + "@googleRoadmap": { + "description": "Nome livello Google Mappa Stradale" + }, + + "googleTerrain": "Google Terreno", + "@googleTerrain": { + "description": "Nome livello Google Terreno (topografico)" + }, + "downloadVisibleArea": "Scarica area visibile", "@downloadVisibleArea": { "description": "Tooltip per il pulsante scarica area visibile" @@ -1673,6 +1748,11 @@ "description": "Messaggio informativo quando un messaggio viene eliminato" }, + "copyText": "Copia testo", + "textCopiedToClipboard": "Testo copiato negli appunti", + "deleteMessage": "Elimina messaggio", + "deleteMessageConfirmation": "Sei sicuro di voler eliminare questo messaggio?", + "refreshedContacts": "Contatti aggiornati", "@refreshedContacts": { "description": "Messaggio di successo quando i contatti vengono aggiornati" @@ -1901,6 +1981,111 @@ "description": "Messaggio di errore quando l'eliminazione MBTiles fallisce" }, + "importExportCachedTiles": "Importa/Esporta tile in cache", + "@importExportCachedTiles": { + "description": "Titolo per sezione di importazione/esportazione" + }, + + "importExportDescription": "Esegui backup, condividi e ripristina tile mappa scaricati tra dispositivi", + "@importExportDescription": { + "description": "Descrizione della funzionalità di importazione/esportazione" + }, + + "exportTilesToFile": "Esporta tile su file", + "@exportTilesToFile": { + "description": "Pulsante per esportare tile" + }, + + "importTilesFromFile": "Importa tile da file", + "@importTilesFromFile": { + "description": "Pulsante per importare tile" + }, + + "selectExportLocation": "Seleziona posizione esportazione", + "@selectExportLocation": { + "description": "Titolo per selettore file di esportazione" + }, + + "selectImportFile": "Seleziona archivio tile", + "@selectImportFile": { + "description": "Titolo per selettore file di importazione" + }, + + "exportingTiles": "Esportazione tile...", + "@exportingTiles": { + "description": "Messaggio di stato durante esportazione" + }, + + "importingTiles": "Importazione tile...", + "@importingTiles": { + "description": "Messaggio di stato durante importazione" + }, + + "exportSuccess": "{count} tile esportati con successo", + "@exportSuccess": { + "description": "Messaggio di successo dopo esportazione", + "placeholders": { + "count": { + "type": "int" + } + } + }, + + "importSuccess": "{count} archivi importati con successo", + "@importSuccess": { + "description": "Messaggio di successo dopo importazione", + "placeholders": { + "count": { + "type": "int" + } + } + }, + + "exportFailed": "Esportazione fallita: {error}", + "@exportFailed": { + "description": "Messaggio di errore quando esportazione fallisce", + "placeholders": { + "error": { + "type": "String" + } + } + }, + + "importFailed": "Importazione fallita: {error}", + "@importFailed": { + "description": "Messaggio di errore quando importazione fallisce", + "placeholders": { + "error": { + "type": "String" + } + } + }, + + "exportNote": "Crea un file archivio compresso (.fmtc) che può essere condiviso e importato su altri dispositivi.", + "@exportNote": { + "description": "Nota sulla funzionalità di esportazione" + }, + + "importNote": "Importa tile mappa da un file archivio precedentemente esportato. I tile verranno uniti con la cache esistente.", + "@importNote": { + "description": "Nota sulla funzionalità di importazione" + }, + + "noTilesToExport": "Nessun tile da esportare", + "@noTilesToExport": { + "description": "Messaggio quando cache è vuota" + }, + + "archiveContainsStores": "L'archivio contiene {count} archivi", + "@archiveContainsStores": { + "description": "Informazioni sul contenuto dell'archivio", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "vectorTiles": "Tile Vettoriali", "@vectorTiles": { "description": "Etichetta per il tipo tile vettoriale" @@ -2139,5 +2324,63 @@ "type": "String" } } - } + }, + + "sarTemplates": "SAR Templates", + "manageSarTemplates": "Manage cursor on target templates", + "addTemplate": "Add Template", + "editTemplate": "Edit Template", + "deleteTemplate": "Delete Template", + "templateName": "Template Name", + "templateNameHint": "e.g. Found Person", + "templateEmoji": "Emoji", + "emojiRequired": "Emoji è obbligatorio", + "nameRequired": "Nome è obbligatorio", + "templateDescription": "Description (Optional)", + "templateDescriptionHint": "Add additional context...", + "templateColor": "Color", + "previewFormat": "Preview (SAR Message Format)", + "importFromClipboard": "Import", + "exportToClipboard": "Export", + "deleteTemplateConfirmation": "Delete template '{name}'?", + "templateAdded": "Template added", + "templateUpdated": "Template updated", + "templateDeleted": "Template deleted", + "templatesImported": "{count, plural, =0{No templates imported} =1{Imported 1 template} other{Imported {count} templates}}", + "templatesExported": "{count, plural, =1{Exported 1 template to clipboard} other{Exported {count} templates to clipboard}}", + "importFailed": "Import failed: {error}", + "exportFailed": "Export failed: {error}", + "resetToDefaults": "Reset to Defaults", + "resetToDefaultsConfirmation": "This will delete all custom templates and restore the 4 default templates. Continue?", + "reset": "Reset", + "resetComplete": "Templates reset to defaults", + "noTemplates": "No templates available", + "tapAddToCreate": "Tap + to create your first template", + "ok": "OK", + "delete": "Elimina", + + "permissionsSection": "Permessi", + "locationPermission": "Permesso di posizione", + "checking": "Verifica in corso...", + "locationPermissionGrantedAlways": "Concesso (Sempre)", + "locationPermissionGrantedWhileInUse": "Concesso (Durante l'uso)", + "locationPermissionDeniedTapToRequest": "Negato - Tocca per richiedere", + "locationPermissionPermanentlyDeniedOpenSettings": "Negato permanentemente - Apri impostazioni", + "locationPermissionDialogContent": "Il permesso di posizione è permanentemente negato. Si prega di abilitarlo nelle impostazioni del dispositivo per utilizzare il tracciamento GPS e la condivisione della posizione.", + "openSettings": "Apri impostazioni", + "locationPermissionGranted": "Permesso di posizione concesso!", + "locationPermissionRequiredForGps": "Il permesso di posizione è necessario per il tracciamento GPS e la condivisione della posizione.", + "locationPermissionAlreadyGranted": "Il permesso di posizione è già concesso.", + "sarNavyBlue": "SAR Blu Navy", + "sarNavyBlueDescription": "Modalità Professionale/Operativa", + + "selectRecipient": "Seleziona destinatario", + "broadcastToAllNearby": "Trasmetti a tutti nelle vicinanze", + "searchRecipients": "Cerca destinatari...", + "noContactsFound": "Nessun contatto trovato", + "noRoomsFound": "Nessuna stanza trovata", + "noContactsOrRoomsAvailable": "Nessun contatto o stanza disponibile", + "messagesWillBeSentToPublicChannel": "I messaggi saranno inviati al canale pubblico", + "newMessage": "Nuovo messaggio", + "channel": "Canale" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index f59ccab..4e733bc 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -330,6 +330,18 @@ abstract class AppLocalizations { /// **'Display packet activity indicators in top bar'** String get displayPacketActivity; + /// Setting to enable simple mode + /// + /// In en, this message translates to: + /// **'Simple Mode'** + String get simpleMode; + + /// Description for simple mode setting + /// + /// In en, this message translates to: + /// **'Hide non-essential information in messages and contacts'** + String get simpleModeDescription; + /// Language setting label /// /// In en, this message translates to: @@ -656,7 +668,7 @@ abstract class AppLocalizations { /// Action to send direct message to contact /// /// In en, this message translates to: - /// **'Send Direct Message'** + /// **'Send'** String get sendDirectMessage; /// Action to reset contact path for re-routing @@ -773,6 +785,42 @@ abstract class AppLocalizations { /// **'Clear All Drawings'** String get clearAllDrawings; + /// Toggle to show/hide received drawings from other team members + /// + /// In en, this message translates to: + /// **'Show Received Drawings'** + String get showReceivedDrawings; + + /// Subtitle when received drawings are visible + /// + /// In en, this message translates to: + /// **'Showing all drawings'** + String get showingAllDrawings; + + /// Subtitle when received drawings are hidden + /// + /// In en, this message translates to: + /// **'Showing only your drawings'** + String get showingOnlyYourDrawings; + + /// Toggle to show/hide SAR markers on map + /// + /// In en, this message translates to: + /// **'Show SAR Markers'** + String get showSarMarkers; + + /// Subtitle when SAR markers are visible + /// + /// In en, this message translates to: + /// **'Showing SAR markers'** + String get showingSarMarkers; + + /// Subtitle when SAR markers are hidden + /// + /// In en, this message translates to: + /// **'Hiding SAR markers'** + String get hidingSarMarkers; + /// Clear all button label /// /// In en, this message translates to: @@ -899,6 +947,12 @@ abstract class AppLocalizations { /// **'Location'** String get location; + /// Button label to insert current GPS location + /// + /// In en, this message translates to: + /// **'My Location'** + String get myLocation; + /// Badge showing location is from map tap /// /// In en, this message translates to: @@ -1247,6 +1301,18 @@ abstract class AppLocalizations { /// **'Accuracy'** String get accuracy; + /// Bearing label in compass + /// + /// In en, this message translates to: + /// **'Bearing'** + String get bearing; + + /// Direction label in compass + /// + /// In en, this message translates to: + /// **'Direction'** + String get direction; + /// Title for filter markers dialog /// /// In en, this message translates to: @@ -1673,6 +1739,24 @@ abstract class AppLocalizations { /// **'ESRI Satellite'** String get esriSatellite; + /// Google Hybrid layer name (satellite + labels) + /// + /// In en, this message translates to: + /// **'Google Hybrid'** + String get googleHybrid; + + /// Google Roadmap layer name (street map) + /// + /// In en, this message translates to: + /// **'Google Roadmap'** + String get googleRoadmap; + + /// Google Terrain layer name (topographic) + /// + /// In en, this message translates to: + /// **'Google Terrain'** + String get googleTerrain; + /// Tooltip for download visible area button /// /// In en, this message translates to: @@ -1805,6 +1889,24 @@ abstract class AppLocalizations { /// **'Message deleted'** String get messageDeleted; + /// Option to copy message text to clipboard + /// + /// In en, this message translates to: + /// **'Copy text'** + String get copyText; + + /// Dialog title for deleting a message + /// + /// In en, this message translates to: + /// **'Delete message'** + String get deleteMessage; + + /// Confirmation text for message deletion + /// + /// In en, this message translates to: + /// **'Are you sure you want to delete this message?'** + String get deleteMessageConfirmation; + /// Success message when contacts are refreshed /// /// In en, this message translates to: @@ -2039,6 +2141,102 @@ abstract class AppLocalizations { /// **'Failed to delete offline map'** String get failedToDeleteMbtiles; + /// Title for import/export section + /// + /// In en, this message translates to: + /// **'Import/Export Cached Tiles'** + String get importExportCachedTiles; + + /// Description for import/export functionality + /// + /// In en, this message translates to: + /// **'Backup, share, and restore downloaded map tiles between devices'** + String get importExportDescription; + + /// Button to export tiles to archive file + /// + /// In en, this message translates to: + /// **'Export Tiles to File'** + String get exportTilesToFile; + + /// Button to import tiles from archive file + /// + /// In en, this message translates to: + /// **'Import Tiles from File'** + String get importTilesFromFile; + + /// Title for export file picker + /// + /// In en, this message translates to: + /// **'Select Export Location'** + String get selectExportLocation; + + /// Title for import file picker + /// + /// In en, this message translates to: + /// **'Select Tile Archive'** + String get selectImportFile; + + /// Status message during export + /// + /// In en, this message translates to: + /// **'Exporting tiles...'** + String get exportingTiles; + + /// Status message during import + /// + /// In en, this message translates to: + /// **'Importing tiles...'** + String get importingTiles; + + /// Success message after export + /// + /// In en, this message translates to: + /// **'Exported {count} tiles successfully'** + String exportSuccess(int count); + + /// Success message after import + /// + /// In en, this message translates to: + /// **'Imported {count} stores successfully'** + String importSuccess(int count); + + /// Error message when export fails + /// + /// In en, this message translates to: + /// **'Export failed: {error}'** + String exportFailed(String error); + + /// Error message when import fails + /// + /// In en, this message translates to: + /// **'Import failed: {error}'** + String importFailed(String error); + + /// Note about export functionality + /// + /// In en, this message translates to: + /// **'Creates a compressed archive (.fmtc) file that can be shared and imported on other devices.'** + String get exportNote; + + /// Note about import functionality + /// + /// In en, this message translates to: + /// **'Imports map tiles from a previously exported archive file. Tiles will be merged with existing cache.'** + String get importNote; + + /// Message when cache is empty + /// + /// In en, this message translates to: + /// **'No tiles available to export'** + String get noTilesToExport; + + /// Information about archive contents + /// + /// In en, this message translates to: + /// **'Archive contains {count} stores'** + String archiveContainsStores(int count); + /// Label for vector tile type /// /// In en, this message translates to: @@ -2296,6 +2494,318 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Failed to get location: {error}'** String failedToGetLocation(String error); + + /// SAR templates menu title + /// + /// In en, this message translates to: + /// **'SAR Templates'** + String get sarTemplates; + + /// Subtitle for SAR templates settings + /// + /// In en, this message translates to: + /// **'Manage cursor on target templates'** + String get manageSarTemplates; + + /// Button to add new SAR template + /// + /// In en, this message translates to: + /// **'Add Template'** + String get addTemplate; + + /// Dialog title for editing template + /// + /// In en, this message translates to: + /// **'Edit Template'** + String get editTemplate; + + /// Action to delete template + /// + /// In en, this message translates to: + /// **'Delete Template'** + String get deleteTemplate; + + /// Label for template name field + /// + /// In en, this message translates to: + /// **'Template Name'** + String get templateName; + + /// Hint text for template name + /// + /// In en, this message translates to: + /// **'e.g. Found Person'** + String get templateNameHint; + + /// Label for template emoji field + /// + /// In en, this message translates to: + /// **'Emoji'** + String get templateEmoji; + + /// Validation error when emoji field is empty + /// + /// In en, this message translates to: + /// **'Emoji is required'** + String get emojiRequired; + + /// Validation error when name field is empty + /// + /// In en, this message translates to: + /// **'Name is required'** + String get nameRequired; + + /// Label for template description field + /// + /// In en, this message translates to: + /// **'Description (Optional)'** + String get templateDescription; + + /// Hint text for template description + /// + /// In en, this message translates to: + /// **'Add additional context...'** + String get templateDescriptionHint; + + /// Label for template color picker + /// + /// In en, this message translates to: + /// **'Color'** + String get templateColor; + + /// Label for format preview + /// + /// In en, this message translates to: + /// **'Preview (SAR Message Format)'** + String get previewFormat; + + /// Button to import templates from clipboard + /// + /// In en, this message translates to: + /// **'Import'** + String get importFromClipboard; + + /// Button to export templates to clipboard + /// + /// In en, this message translates to: + /// **'Export'** + String get exportToClipboard; + + /// Confirmation message for template deletion + /// + /// In en, this message translates to: + /// **'Delete template \'{name}\'?'** + String deleteTemplateConfirmation(String name); + + /// Success message when template is added + /// + /// In en, this message translates to: + /// **'Template added'** + String get templateAdded; + + /// Success message when template is updated + /// + /// In en, this message translates to: + /// **'Template updated'** + String get templateUpdated; + + /// Success message when template is deleted + /// + /// In en, this message translates to: + /// **'Template deleted'** + String get templateDeleted; + + /// Success message after importing templates + /// + /// In en, this message translates to: + /// **'{count, plural, =0{No templates imported} =1{Imported 1 template} other{Imported {count} templates}}'** + String templatesImported(int count); + + /// Success message after exporting templates + /// + /// In en, this message translates to: + /// **'{count, plural, =1{Exported 1 template to clipboard} other{Exported {count} templates to clipboard}}'** + String templatesExported(int count); + + /// Action to reset templates to defaults + /// + /// In en, this message translates to: + /// **'Reset to Defaults'** + String get resetToDefaults; + + /// Confirmation message for reset to defaults + /// + /// In en, this message translates to: + /// **'This will delete all custom templates and restore the 4 default templates. Continue?'** + String get resetToDefaultsConfirmation; + + /// Reset button label + /// + /// In en, this message translates to: + /// **'Reset'** + String get reset; + + /// Success message after reset + /// + /// In en, this message translates to: + /// **'Templates reset to defaults'** + String get resetComplete; + + /// Message when no templates exist + /// + /// In en, this message translates to: + /// **'No templates available'** + String get noTemplates; + + /// Helper text when no templates exist + /// + /// In en, this message translates to: + /// **'Tap + to create your first template'** + String get tapAddToCreate; + + /// OK button label + /// + /// In en, this message translates to: + /// **'OK'** + String get ok; + + /// Permissions section header + /// + /// In en, this message translates to: + /// **'Permissions'** + String get permissionsSection; + + /// Location permission label + /// + /// In en, this message translates to: + /// **'Location Permission'** + String get locationPermission; + + /// Loading state indicator + /// + /// In en, this message translates to: + /// **'Checking...'** + String get checking; + + /// Location permission status: granted always + /// + /// In en, this message translates to: + /// **'Granted (Always)'** + String get locationPermissionGrantedAlways; + + /// Location permission status: granted while in use + /// + /// In en, this message translates to: + /// **'Granted (While In Use)'** + String get locationPermissionGrantedWhileInUse; + + /// Location permission status: denied, user can request + /// + /// In en, this message translates to: + /// **'Denied - Tap to request'** + String get locationPermissionDeniedTapToRequest; + + /// Location permission status: permanently denied + /// + /// In en, this message translates to: + /// **'Permanently Denied - Open Settings'** + String get locationPermissionPermanentlyDeniedOpenSettings; + + /// Content for location permission dialog when permanently denied + /// + /// In en, this message translates to: + /// **'Location permission is permanently denied. Please enable it in your device settings to use GPS tracking and location sharing features.'** + String get locationPermissionDialogContent; + + /// Button to open device settings + /// + /// In en, this message translates to: + /// **'Open Settings'** + String get openSettings; + + /// Success message when location permission is granted + /// + /// In en, this message translates to: + /// **'Location permission granted!'** + String get locationPermissionGranted; + + /// Info message about location permission requirement + /// + /// In en, this message translates to: + /// **'Location permission is required for GPS tracking and location sharing.'** + String get locationPermissionRequiredForGps; + + /// Info message when permission is already granted + /// + /// In en, this message translates to: + /// **'Location permission is already granted.'** + String get locationPermissionAlreadyGranted; + + /// SAR Navy Blue theme name + /// + /// In en, this message translates to: + /// **'SAR Navy Blue'** + String get sarNavyBlue; + + /// Description for SAR Navy Blue theme + /// + /// In en, this message translates to: + /// **'Professional/Operations Mode'** + String get sarNavyBlueDescription; + + /// Title for recipient selector sheet + /// + /// In en, this message translates to: + /// **'Select Recipient'** + String get selectRecipient; + + /// Subtitle for public channel option + /// + /// In en, this message translates to: + /// **'Broadcast to all nearby'** + String get broadcastToAllNearby; + + /// Placeholder text for recipient search field + /// + /// In en, this message translates to: + /// **'Search recipients...'** + String get searchRecipients; + + /// Message when no contacts match search + /// + /// In en, this message translates to: + /// **'No contacts found'** + String get noContactsFound; + + /// Message when no rooms match search + /// + /// In en, this message translates to: + /// **'No rooms found'** + String get noRoomsFound; + + /// Message when no contacts or rooms exist + /// + /// In en, this message translates to: + /// **'No contacts or rooms available'** + String get noContactsOrRoomsAvailable; + + /// Info message when only public channel is available + /// + /// In en, this message translates to: + /// **'Messages will be sent to public channel'** + String get messagesWillBeSentToPublicChannel; + + /// Notification title for new message + /// + /// In en, this message translates to: + /// **'New message'** + String get newMessage; + + /// Channel label in notifications + /// + /// In en, this message translates to: + /// **'Channel'** + String get channel; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 67df9b2..24a628f 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -130,6 +130,13 @@ class AppLocalizationsDe extends AppLocalizations { String get displayPacketActivity => 'Paketaktivitätsindikatoren in der oberen Leiste anzeigen'; + @override + String get simpleMode => 'Einfacher Modus'; + + @override + String get simpleModeDescription => + 'Nicht wesentliche Informationen in Nachrichten und Kontakten ausblenden'; + @override String get language => 'Sprache'; @@ -316,7 +323,7 @@ class AppLocalizationsDe extends AppLocalizations { String get refresh => 'Aktualisieren'; @override - String get sendDirectMessage => 'Direktnachricht senden'; + String get sendDirectMessage => 'Senden'; @override String get resetPath => 'Pfad zurücksetzen (Umleitung)'; @@ -385,6 +392,24 @@ class AppLocalizationsDe extends AppLocalizations { @override String get clearAllDrawings => 'Alle Zeichnungen löschen'; + @override + String get showReceivedDrawings => 'Empfangene Zeichnungen anzeigen'; + + @override + String get showingAllDrawings => 'Alle Zeichnungen werden angezeigt'; + + @override + String get showingOnlyYourDrawings => 'Nur Ihre Zeichnungen werden angezeigt'; + + @override + String get showSarMarkers => 'SAR-Markierungen anzeigen'; + + @override + String get showingSarMarkers => 'SAR-Markierungen werden angezeigt'; + + @override + String get hidingSarMarkers => 'SAR-Markierungen ausgeblendet'; + @override String get clearAll => 'Alle löschen'; @@ -460,6 +485,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get location => 'Standort'; + @override + String get myLocation => 'Mein Standort'; + @override String get fromMap => 'Von Karte'; @@ -664,6 +692,12 @@ class AppLocalizationsDe extends AppLocalizations { @override String get accuracy => 'Genauigkeit'; + @override + String get bearing => 'Peilung'; + + @override + String get direction => 'Richtung'; + @override String get filterMarkers => 'Markierungen filtern'; @@ -906,6 +940,15 @@ class AppLocalizationsDe extends AppLocalizations { @override String get esriSatellite => 'ESRI-Satellit'; + @override + String get googleHybrid => 'Google Hybrid'; + + @override + String get googleRoadmap => 'Google Straßenkarte'; + + @override + String get googleTerrain => 'Google Gelände'; + @override String get downloadVisibleArea => 'Sichtbaren Bereich herunterladen'; @@ -988,6 +1031,16 @@ class AppLocalizationsDe extends AppLocalizations { @override String get messageDeleted => 'Nachricht gelöscht'; + @override + String get copyText => 'Text kopieren'; + + @override + String get deleteMessage => 'Nachricht löschen'; + + @override + String get deleteMessageConfirmation => + 'Möchten Sie diese Nachricht wirklich löschen?'; + @override String get refreshedContacts => 'Kontakte aktualisiert'; @@ -1123,6 +1176,67 @@ class AppLocalizationsDe extends AppLocalizations { @override String get failedToDeleteMbtiles => 'Fehler beim Löschen der Offline-Karte'; + @override + String get importExportCachedTiles => 'Import/Export gecachter Kacheln'; + + @override + String get importExportDescription => + 'Sichern, teilen und wiederherstellen Sie heruntergeladene Kartenkacheln zwischen Geräten'; + + @override + String get exportTilesToFile => 'Kacheln in Datei exportieren'; + + @override + String get importTilesFromFile => 'Kacheln aus Datei importieren'; + + @override + String get selectExportLocation => 'Exportspeicherort wählen'; + + @override + String get selectImportFile => 'Kachel-Archiv auswählen'; + + @override + String get exportingTiles => 'Exportiere Kacheln...'; + + @override + String get importingTiles => 'Importiere Kacheln...'; + + @override + String exportSuccess(int count) { + return '$count Kacheln erfolgreich exportiert'; + } + + @override + String importSuccess(int count) { + return '$count Speicher erfolgreich importiert'; + } + + @override + String exportFailed(String error) { + return 'Export failed: $error'; + } + + @override + String importFailed(String error) { + return 'Import failed: $error'; + } + + @override + String get exportNote => + 'Erstellt eine komprimierte Archivdatei (.fmtc), die auf anderen Geräten geteilt und importiert werden kann.'; + + @override + String get importNote => + 'Importiert Kartenkacheln aus einer zuvor exportierten Archivdatei. Kacheln werden mit dem vorhandenen Cache zusammengeführt.'; + + @override + String get noTilesToExport => 'Keine Kacheln zum Exportieren verfügbar'; + + @override + String archiveContainsStores(int count) { + return 'Archiv enthält $count Speicher'; + } + @override String get vectorTiles => 'Vektor-Tiles'; @@ -1263,4 +1377,188 @@ class AppLocalizationsDe extends AppLocalizations { String failedToGetLocation(String error) { return 'Fehler beim Abrufen des Standorts: $error'; } + + @override + String get sarTemplates => 'SAR Templates'; + + @override + String get manageSarTemplates => 'Manage cursor on target templates'; + + @override + String get addTemplate => 'Add Template'; + + @override + String get editTemplate => 'Edit Template'; + + @override + String get deleteTemplate => 'Delete Template'; + + @override + String get templateName => 'Template Name'; + + @override + String get templateNameHint => 'e.g. Found Person'; + + @override + String get templateEmoji => 'Emoji'; + + @override + String get emojiRequired => 'Emoji ist erforderlich'; + + @override + String get nameRequired => 'Name ist erforderlich'; + + @override + String get templateDescription => 'Description (Optional)'; + + @override + String get templateDescriptionHint => 'Add additional context...'; + + @override + String get templateColor => 'Color'; + + @override + String get previewFormat => 'Preview (SAR Message Format)'; + + @override + String get importFromClipboard => 'Import'; + + @override + String get exportToClipboard => 'Export'; + + @override + String deleteTemplateConfirmation(String name) { + return 'Delete template \'$name\'?'; + } + + @override + String get templateAdded => 'Template added'; + + @override + String get templateUpdated => 'Template updated'; + + @override + String get templateDeleted => 'Template deleted'; + + @override + String templatesImported(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Imported $count templates', + one: 'Imported 1 template', + zero: 'No templates imported', + ); + return '$_temp0'; + } + + @override + String templatesExported(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Exported $count templates to clipboard', + one: 'Exported 1 template to clipboard', + ); + return '$_temp0'; + } + + @override + String get resetToDefaults => 'Reset to Defaults'; + + @override + String get resetToDefaultsConfirmation => + 'This will delete all custom templates and restore the 4 default templates. Continue?'; + + @override + String get reset => 'Reset'; + + @override + String get resetComplete => 'Templates reset to defaults'; + + @override + String get noTemplates => 'No templates available'; + + @override + String get tapAddToCreate => 'Tap + to create your first template'; + + @override + String get ok => 'OK'; + + @override + String get permissionsSection => 'Berechtigungen'; + + @override + String get locationPermission => 'Standortberechtigung'; + + @override + String get checking => 'Überprüfen...'; + + @override + String get locationPermissionGrantedAlways => 'Erteilt (Immer)'; + + @override + String get locationPermissionGrantedWhileInUse => + 'Erteilt (Während der Nutzung)'; + + @override + String get locationPermissionDeniedTapToRequest => + 'Verweigert - Tippen zum Anfragen'; + + @override + String get locationPermissionPermanentlyDeniedOpenSettings => + 'Dauerhaft verweigert - Einstellungen öffnen'; + + @override + String get locationPermissionDialogContent => + 'Die Standortberechtigung wurde dauerhaft verweigert. Bitte aktivieren Sie sie in Ihren Geräteeinstellungen, um GPS-Tracking und Standortfreigabe zu nutzen.'; + + @override + String get openSettings => 'Einstellungen öffnen'; + + @override + String get locationPermissionGranted => 'Standortberechtigung erteilt!'; + + @override + String get locationPermissionRequiredForGps => + 'Die Standortberechtigung ist erforderlich für GPS-Tracking und Standortfreigabe.'; + + @override + String get locationPermissionAlreadyGranted => + 'Die Standortberechtigung wurde bereits erteilt.'; + + @override + String get sarNavyBlue => 'SAR Navy Blau'; + + @override + String get sarNavyBlueDescription => 'Professionell/Einsatzmodus'; + + @override + String get selectRecipient => 'Empfänger auswählen'; + + @override + String get broadcastToAllNearby => 'An alle in der Nähe senden'; + + @override + String get searchRecipients => 'Empfänger suchen...'; + + @override + String get noContactsFound => 'Keine Kontakte gefunden'; + + @override + String get noRoomsFound => 'Keine Räume gefunden'; + + @override + String get noContactsOrRoomsAvailable => + 'Keine Kontakte oder Räume verfügbar'; + + @override + String get messagesWillBeSentToPublicChannel => + 'Nachrichten werden an öffentlichen Kanal gesendet'; + + @override + String get newMessage => 'Neue Nachricht'; + + @override + String get channel => 'Kanal'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index c116d9f..8df993c 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -129,6 +129,13 @@ class AppLocalizationsEn extends AppLocalizations { String get displayPacketActivity => 'Display packet activity indicators in top bar'; + @override + String get simpleMode => 'Simple Mode'; + + @override + String get simpleModeDescription => + 'Hide non-essential information in messages and contacts'; + @override String get language => 'Language'; @@ -314,7 +321,7 @@ class AppLocalizationsEn extends AppLocalizations { String get refresh => 'Refresh'; @override - String get sendDirectMessage => 'Send Direct Message'; + String get sendDirectMessage => 'Send'; @override String get resetPath => 'Reset Path (Re-route)'; @@ -382,6 +389,24 @@ class AppLocalizationsEn extends AppLocalizations { @override String get clearAllDrawings => 'Clear All Drawings'; + @override + String get showReceivedDrawings => 'Show Received Drawings'; + + @override + String get showingAllDrawings => 'Showing all drawings'; + + @override + String get showingOnlyYourDrawings => 'Showing only your drawings'; + + @override + String get showSarMarkers => 'Show SAR Markers'; + + @override + String get showingSarMarkers => 'Showing SAR markers'; + + @override + String get hidingSarMarkers => 'Hiding SAR markers'; + @override String get clearAll => 'Clear All'; @@ -457,6 +482,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get location => 'Location'; + @override + String get myLocation => 'My Location'; + @override String get fromMap => 'From Map'; @@ -660,6 +688,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get accuracy => 'Accuracy'; + @override + String get bearing => 'Bearing'; + + @override + String get direction => 'Direction'; + @override String get filterMarkers => 'Filter Markers'; @@ -900,6 +934,15 @@ class AppLocalizationsEn extends AppLocalizations { @override String get esriSatellite => 'ESRI Satellite'; + @override + String get googleHybrid => 'Google Hybrid'; + + @override + String get googleRoadmap => 'Google Roadmap'; + + @override + String get googleTerrain => 'Google Terrain'; + @override String get downloadVisibleArea => 'Download visible area'; @@ -978,6 +1021,16 @@ class AppLocalizationsEn extends AppLocalizations { @override String get messageDeleted => 'Message deleted'; + @override + String get copyText => 'Copy text'; + + @override + String get deleteMessage => 'Delete message'; + + @override + String get deleteMessageConfirmation => + 'Are you sure you want to delete this message?'; + @override String get refreshedContacts => 'Refreshed contacts'; @@ -1112,6 +1165,67 @@ class AppLocalizationsEn extends AppLocalizations { @override String get failedToDeleteMbtiles => 'Failed to delete offline map'; + @override + String get importExportCachedTiles => 'Import/Export Cached Tiles'; + + @override + String get importExportDescription => + 'Backup, share, and restore downloaded map tiles between devices'; + + @override + String get exportTilesToFile => 'Export Tiles to File'; + + @override + String get importTilesFromFile => 'Import Tiles from File'; + + @override + String get selectExportLocation => 'Select Export Location'; + + @override + String get selectImportFile => 'Select Tile Archive'; + + @override + String get exportingTiles => 'Exporting tiles...'; + + @override + String get importingTiles => 'Importing tiles...'; + + @override + String exportSuccess(int count) { + return 'Exported $count tiles successfully'; + } + + @override + String importSuccess(int count) { + return 'Imported $count stores successfully'; + } + + @override + String exportFailed(String error) { + return 'Export failed: $error'; + } + + @override + String importFailed(String error) { + return 'Import failed: $error'; + } + + @override + String get exportNote => + 'Creates a compressed archive (.fmtc) file that can be shared and imported on other devices.'; + + @override + String get importNote => + 'Imports map tiles from a previously exported archive file. Tiles will be merged with existing cache.'; + + @override + String get noTilesToExport => 'No tiles available to export'; + + @override + String archiveContainsStores(int count) { + return 'Archive contains $count stores'; + } + @override String get vectorTiles => 'Vector Tiles'; @@ -1250,4 +1364,185 @@ class AppLocalizationsEn extends AppLocalizations { String failedToGetLocation(String error) { return 'Failed to get location: $error'; } + + @override + String get sarTemplates => 'SAR Templates'; + + @override + String get manageSarTemplates => 'Manage cursor on target templates'; + + @override + String get addTemplate => 'Add Template'; + + @override + String get editTemplate => 'Edit Template'; + + @override + String get deleteTemplate => 'Delete Template'; + + @override + String get templateName => 'Template Name'; + + @override + String get templateNameHint => 'e.g. Found Person'; + + @override + String get templateEmoji => 'Emoji'; + + @override + String get emojiRequired => 'Emoji is required'; + + @override + String get nameRequired => 'Name is required'; + + @override + String get templateDescription => 'Description (Optional)'; + + @override + String get templateDescriptionHint => 'Add additional context...'; + + @override + String get templateColor => 'Color'; + + @override + String get previewFormat => 'Preview (SAR Message Format)'; + + @override + String get importFromClipboard => 'Import'; + + @override + String get exportToClipboard => 'Export'; + + @override + String deleteTemplateConfirmation(String name) { + return 'Delete template \'$name\'?'; + } + + @override + String get templateAdded => 'Template added'; + + @override + String get templateUpdated => 'Template updated'; + + @override + String get templateDeleted => 'Template deleted'; + + @override + String templatesImported(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Imported $count templates', + one: 'Imported 1 template', + zero: 'No templates imported', + ); + return '$_temp0'; + } + + @override + String templatesExported(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Exported $count templates to clipboard', + one: 'Exported 1 template to clipboard', + ); + return '$_temp0'; + } + + @override + String get resetToDefaults => 'Reset to Defaults'; + + @override + String get resetToDefaultsConfirmation => + 'This will delete all custom templates and restore the 4 default templates. Continue?'; + + @override + String get reset => 'Reset'; + + @override + String get resetComplete => 'Templates reset to defaults'; + + @override + String get noTemplates => 'No templates available'; + + @override + String get tapAddToCreate => 'Tap + to create your first template'; + + @override + String get ok => 'OK'; + + @override + String get permissionsSection => 'Permissions'; + + @override + String get locationPermission => 'Location Permission'; + + @override + String get checking => 'Checking...'; + + @override + String get locationPermissionGrantedAlways => 'Granted (Always)'; + + @override + String get locationPermissionGrantedWhileInUse => 'Granted (While In Use)'; + + @override + String get locationPermissionDeniedTapToRequest => 'Denied - Tap to request'; + + @override + String get locationPermissionPermanentlyDeniedOpenSettings => + 'Permanently Denied - Open Settings'; + + @override + String get locationPermissionDialogContent => + 'Location permission is permanently denied. Please enable it in your device settings to use GPS tracking and location sharing features.'; + + @override + String get openSettings => 'Open Settings'; + + @override + String get locationPermissionGranted => 'Location permission granted!'; + + @override + String get locationPermissionRequiredForGps => + 'Location permission is required for GPS tracking and location sharing.'; + + @override + String get locationPermissionAlreadyGranted => + 'Location permission is already granted.'; + + @override + String get sarNavyBlue => 'SAR Navy Blue'; + + @override + String get sarNavyBlueDescription => 'Professional/Operations Mode'; + + @override + String get selectRecipient => 'Select Recipient'; + + @override + String get broadcastToAllNearby => 'Broadcast to all nearby'; + + @override + String get searchRecipients => 'Search recipients...'; + + @override + String get noContactsFound => 'No contacts found'; + + @override + String get noRoomsFound => 'No rooms found'; + + @override + String get noContactsOrRoomsAvailable => 'No contacts or rooms available'; + + @override + String get messagesWillBeSentToPublicChannel => + 'Messages will be sent to public channel'; + + @override + String get newMessage => 'New message'; + + @override + String get channel => 'Channel'; } diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index a1e389d..fd191e3 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -129,6 +129,13 @@ class AppLocalizationsEs extends AppLocalizations { String get displayPacketActivity => 'Mostrar indicadores de actividad de paquetes en la barra superior'; + @override + String get simpleMode => 'Modo Simple'; + + @override + String get simpleModeDescription => + 'Ocultar información no esencial en mensajes y contactos'; + @override String get language => 'Idioma'; @@ -315,7 +322,7 @@ class AppLocalizationsEs extends AppLocalizations { String get refresh => 'Actualizar'; @override - String get sendDirectMessage => 'Enviar mensaje directo'; + String get sendDirectMessage => 'Enviar'; @override String get resetPath => 'Restablecer ruta (Re-enrutar)'; @@ -383,6 +390,24 @@ class AppLocalizationsEs extends AppLocalizations { @override String get clearAllDrawings => 'Borrar todos los dibujos'; + @override + String get showReceivedDrawings => 'Mostrar dibujos recibidos'; + + @override + String get showingAllDrawings => 'Mostrando todos los dibujos'; + + @override + String get showingOnlyYourDrawings => 'Mostrando solo tus dibujos'; + + @override + String get showSarMarkers => 'Mostrar marcadores SAR'; + + @override + String get showingSarMarkers => 'Mostrando marcadores SAR'; + + @override + String get hidingSarMarkers => 'Ocultando marcadores SAR'; + @override String get clearAll => 'Borrar todo'; @@ -458,6 +483,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get location => 'Ubicación'; + @override + String get myLocation => 'Mi ubicación'; + @override String get fromMap => 'Desde el mapa'; @@ -661,6 +689,12 @@ class AppLocalizationsEs extends AppLocalizations { @override String get accuracy => 'Precisión'; + @override + String get bearing => 'Rumbo'; + + @override + String get direction => 'Dirección'; + @override String get filterMarkers => 'Filtrar marcadores'; @@ -904,6 +938,15 @@ class AppLocalizationsEs extends AppLocalizations { @override String get esriSatellite => 'ESRI Satélite'; + @override + String get googleHybrid => 'Google Híbrido'; + + @override + String get googleRoadmap => 'Google Mapa de Carreteras'; + + @override + String get googleTerrain => 'Google Terreno'; + @override String get downloadVisibleArea => 'Descargar área visible'; @@ -984,6 +1027,16 @@ class AppLocalizationsEs extends AppLocalizations { @override String get messageDeleted => 'Mensaje eliminado'; + @override + String get copyText => 'Copiar texto'; + + @override + String get deleteMessage => 'Eliminar mensaje'; + + @override + String get deleteMessageConfirmation => + '¿Está seguro de que desea eliminar este mensaje?'; + @override String get refreshedContacts => 'Contactos actualizados'; @@ -1120,6 +1173,67 @@ class AppLocalizationsEs extends AppLocalizations { @override String get failedToDeleteMbtiles => 'Error al eliminar mapa sin conexión'; + @override + String get importExportCachedTiles => 'Importar/Exportar teselas en caché'; + + @override + String get importExportDescription => + 'Realice copias de seguridad, comparta y restaure teselas de mapas descargadas entre dispositivos'; + + @override + String get exportTilesToFile => 'Exportar teselas a archivo'; + + @override + String get importTilesFromFile => 'Importar teselas desde archivo'; + + @override + String get selectExportLocation => 'Seleccionar ubicación de exportación'; + + @override + String get selectImportFile => 'Seleccionar archivo de teselas'; + + @override + String get exportingTiles => 'Exportando teselas...'; + + @override + String get importingTiles => 'Importando teselas...'; + + @override + String exportSuccess(int count) { + return '$count teselas exportadas exitosamente'; + } + + @override + String importSuccess(int count) { + return '$count almacenes importados exitosamente'; + } + + @override + String exportFailed(String error) { + return 'Export failed: $error'; + } + + @override + String importFailed(String error) { + return 'Import failed: $error'; + } + + @override + String get exportNote => + 'Crea un archivo comprimido (.fmtc) que se puede compartir e importar en otros dispositivos.'; + + @override + String get importNote => + 'Importa teselas de mapa desde un archivo previamente exportado. Las teselas se fusionarán con la caché existente.'; + + @override + String get noTilesToExport => 'No hay teselas para exportar'; + + @override + String archiveContainsStores(int count) { + return 'El archivo contiene $count almacenes'; + } + @override String get vectorTiles => 'Teselas vectoriales'; @@ -1258,4 +1372,188 @@ class AppLocalizationsEs extends AppLocalizations { String failedToGetLocation(String error) { return 'Error al obtener ubicación: $error'; } + + @override + String get sarTemplates => 'SAR Templates'; + + @override + String get manageSarTemplates => 'Manage cursor on target templates'; + + @override + String get addTemplate => 'Add Template'; + + @override + String get editTemplate => 'Edit Template'; + + @override + String get deleteTemplate => 'Delete Template'; + + @override + String get templateName => 'Template Name'; + + @override + String get templateNameHint => 'e.g. Found Person'; + + @override + String get templateEmoji => 'Emoji'; + + @override + String get emojiRequired => 'Se requiere emoji'; + + @override + String get nameRequired => 'Se requiere nombre'; + + @override + String get templateDescription => 'Description (Optional)'; + + @override + String get templateDescriptionHint => 'Add additional context...'; + + @override + String get templateColor => 'Color'; + + @override + String get previewFormat => 'Preview (SAR Message Format)'; + + @override + String get importFromClipboard => 'Import'; + + @override + String get exportToClipboard => 'Export'; + + @override + String deleteTemplateConfirmation(String name) { + return 'Delete template \'$name\'?'; + } + + @override + String get templateAdded => 'Template added'; + + @override + String get templateUpdated => 'Template updated'; + + @override + String get templateDeleted => 'Template deleted'; + + @override + String templatesImported(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Imported $count templates', + one: 'Imported 1 template', + zero: 'No templates imported', + ); + return '$_temp0'; + } + + @override + String templatesExported(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Exported $count templates to clipboard', + one: 'Exported 1 template to clipboard', + ); + return '$_temp0'; + } + + @override + String get resetToDefaults => 'Reset to Defaults'; + + @override + String get resetToDefaultsConfirmation => + 'This will delete all custom templates and restore the 4 default templates. Continue?'; + + @override + String get reset => 'Reset'; + + @override + String get resetComplete => 'Templates reset to defaults'; + + @override + String get noTemplates => 'No templates available'; + + @override + String get tapAddToCreate => 'Tap + to create your first template'; + + @override + String get ok => 'OK'; + + @override + String get permissionsSection => 'Permisos'; + + @override + String get locationPermission => 'Permiso de ubicación'; + + @override + String get checking => 'Comprobando...'; + + @override + String get locationPermissionGrantedAlways => 'Concedido (Siempre)'; + + @override + String get locationPermissionGrantedWhileInUse => + 'Concedido (Durante el uso)'; + + @override + String get locationPermissionDeniedTapToRequest => + 'Denegado - Toca para solicitar'; + + @override + String get locationPermissionPermanentlyDeniedOpenSettings => + 'Denegado permanentemente - Abrir ajustes'; + + @override + String get locationPermissionDialogContent => + 'El permiso de ubicación está permanentemente denegado. Por favor, actívalo en la configuración de tu dispositivo para usar el rastreo GPS y compartir ubicación.'; + + @override + String get openSettings => 'Abrir ajustes'; + + @override + String get locationPermissionGranted => '¡Permiso de ubicación concedido!'; + + @override + String get locationPermissionRequiredForGps => + 'El permiso de ubicación es necesario para el rastreo GPS y compartir ubicación.'; + + @override + String get locationPermissionAlreadyGranted => + 'El permiso de ubicación ya está concedido.'; + + @override + String get sarNavyBlue => 'SAR Azul Marino'; + + @override + String get sarNavyBlueDescription => 'Modo Profesional/Operaciones'; + + @override + String get selectRecipient => 'Seleccionar destinatario'; + + @override + String get broadcastToAllNearby => 'Transmitir a todos cercanos'; + + @override + String get searchRecipients => 'Buscar destinatarios...'; + + @override + String get noContactsFound => 'No se encontraron contactos'; + + @override + String get noRoomsFound => 'No se encontraron salas'; + + @override + String get noContactsOrRoomsAvailable => + 'No hay contactos o salas disponibles'; + + @override + String get messagesWillBeSentToPublicChannel => + 'Los mensajes se enviarán al canal público'; + + @override + String get newMessage => 'Nuevo mensaje'; + + @override + String get channel => 'Canal'; } diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 18148a2..6316c16 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -130,6 +130,13 @@ class AppLocalizationsFr extends AppLocalizations { String get displayPacketActivity => 'Afficher les indicateurs d\'activité des paquets dans la barre supérieure'; + @override + String get simpleMode => 'Mode Simple'; + + @override + String get simpleModeDescription => + 'Masquer les informations non essentielles dans les messages et les contacts'; + @override String get language => 'Langue'; @@ -317,7 +324,7 @@ class AppLocalizationsFr extends AppLocalizations { String get refresh => 'Actualiser'; @override - String get sendDirectMessage => 'Envoyer un message direct'; + String get sendDirectMessage => 'Envoyer'; @override String get resetPath => 'Réinitialiser le chemin (Re-router)'; @@ -385,6 +392,24 @@ class AppLocalizationsFr extends AppLocalizations { @override String get clearAllDrawings => 'Effacer tous les dessins'; + @override + String get showReceivedDrawings => 'Afficher les dessins reçus'; + + @override + String get showingAllDrawings => 'Affichage de tous les dessins'; + + @override + String get showingOnlyYourDrawings => 'Affichage uniquement de vos dessins'; + + @override + String get showSarMarkers => 'Afficher les marqueurs SAR'; + + @override + String get showingSarMarkers => 'Affichage des marqueurs SAR'; + + @override + String get hidingSarMarkers => 'Masquage des marqueurs SAR'; + @override String get clearAll => 'Tout effacer'; @@ -461,6 +486,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get location => 'Position'; + @override + String get myLocation => 'Ma position'; + @override String get fromMap => 'Depuis la carte'; @@ -665,6 +693,12 @@ class AppLocalizationsFr extends AppLocalizations { @override String get accuracy => 'Précision'; + @override + String get bearing => 'Relèvement'; + + @override + String get direction => 'Direction'; + @override String get filterMarkers => 'Filtrer les marqueurs'; @@ -909,6 +943,15 @@ class AppLocalizationsFr extends AppLocalizations { @override String get esriSatellite => 'Satellite ESRI'; + @override + String get googleHybrid => 'Google Hybride'; + + @override + String get googleRoadmap => 'Google Carte Routière'; + + @override + String get googleTerrain => 'Google Terrain'; + @override String get downloadVisibleArea => 'Télécharger la zone visible'; @@ -989,6 +1032,16 @@ class AppLocalizationsFr extends AppLocalizations { @override String get messageDeleted => 'Message supprimé'; + @override + String get copyText => 'Copier le texte'; + + @override + String get deleteMessage => 'Supprimer le message'; + + @override + String get deleteMessageConfirmation => + 'Êtes-vous sûr de vouloir supprimer ce message?'; + @override String get refreshedContacts => 'Contacts actualisés'; @@ -1126,6 +1179,68 @@ class AppLocalizationsFr extends AppLocalizations { String get failedToDeleteMbtiles => 'Échec de la suppression de la carte hors ligne'; + @override + String get importExportCachedTiles => 'Importer/Exporter les tuiles en cache'; + + @override + String get importExportDescription => + 'Sauvegarder, partager et restaurer les tuiles de carte téléchargées entre appareils'; + + @override + String get exportTilesToFile => 'Exporter les tuiles vers fichier'; + + @override + String get importTilesFromFile => 'Importer les tuiles depuis fichier'; + + @override + String get selectExportLocation => + 'Sélectionner l\'emplacement d\'exportation'; + + @override + String get selectImportFile => 'Sélectionner l\'archive de tuiles'; + + @override + String get exportingTiles => 'Exportation des tuiles...'; + + @override + String get importingTiles => 'Importation des tuiles...'; + + @override + String exportSuccess(int count) { + return '$count tuiles exportées avec succès'; + } + + @override + String importSuccess(int count) { + return '$count magasins importés avec succès'; + } + + @override + String exportFailed(String error) { + return 'Export failed: $error'; + } + + @override + String importFailed(String error) { + return 'Import failed: $error'; + } + + @override + String get exportNote => + 'Crée un fichier d\'archive compressé (.fmtc) qui peut être partagé et importé sur d\'autres appareils.'; + + @override + String get importNote => + 'Importe les tuiles de carte depuis un fichier d\'archive précédemment exporté. Les tuiles seront fusionnées avec le cache existant.'; + + @override + String get noTilesToExport => 'Aucune tuile à exporter'; + + @override + String archiveContainsStores(int count) { + return 'L\'archive contient $count magasins'; + } + @override String get vectorTiles => 'Tuiles vectorielles'; @@ -1265,4 +1380,188 @@ class AppLocalizationsFr extends AppLocalizations { String failedToGetLocation(String error) { return 'Échec de l\'obtention de la position : $error'; } + + @override + String get sarTemplates => 'SAR Templates'; + + @override + String get manageSarTemplates => 'Manage cursor on target templates'; + + @override + String get addTemplate => 'Add Template'; + + @override + String get editTemplate => 'Edit Template'; + + @override + String get deleteTemplate => 'Delete Template'; + + @override + String get templateName => 'Template Name'; + + @override + String get templateNameHint => 'e.g. Found Person'; + + @override + String get templateEmoji => 'Emoji'; + + @override + String get emojiRequired => 'Emoji est requis'; + + @override + String get nameRequired => 'Nom est requis'; + + @override + String get templateDescription => 'Description (Optional)'; + + @override + String get templateDescriptionHint => 'Add additional context...'; + + @override + String get templateColor => 'Color'; + + @override + String get previewFormat => 'Preview (SAR Message Format)'; + + @override + String get importFromClipboard => 'Import'; + + @override + String get exportToClipboard => 'Export'; + + @override + String deleteTemplateConfirmation(String name) { + return 'Delete template \'$name\'?'; + } + + @override + String get templateAdded => 'Template added'; + + @override + String get templateUpdated => 'Template updated'; + + @override + String get templateDeleted => 'Template deleted'; + + @override + String templatesImported(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Imported $count templates', + one: 'Imported 1 template', + zero: 'No templates imported', + ); + return '$_temp0'; + } + + @override + String templatesExported(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Exported $count templates to clipboard', + one: 'Exported 1 template to clipboard', + ); + return '$_temp0'; + } + + @override + String get resetToDefaults => 'Reset to Defaults'; + + @override + String get resetToDefaultsConfirmation => + 'This will delete all custom templates and restore the 4 default templates. Continue?'; + + @override + String get reset => 'Reset'; + + @override + String get resetComplete => 'Templates reset to defaults'; + + @override + String get noTemplates => 'No templates available'; + + @override + String get tapAddToCreate => 'Tap + to create your first template'; + + @override + String get ok => 'OK'; + + @override + String get permissionsSection => 'Autorisations'; + + @override + String get locationPermission => 'Autorisation de localisation'; + + @override + String get checking => 'Vérification...'; + + @override + String get locationPermissionGrantedAlways => 'Accordée (Toujours)'; + + @override + String get locationPermissionGrantedWhileInUse => + 'Accordée (En cours d\'utilisation)'; + + @override + String get locationPermissionDeniedTapToRequest => + 'Refusée - Appuyez pour demander'; + + @override + String get locationPermissionPermanentlyDeniedOpenSettings => + 'Refusée définitivement - Ouvrir les paramètres'; + + @override + String get locationPermissionDialogContent => + 'L\'autorisation de localisation est définitivement refusée. Veuillez l\'activer dans les paramètres de votre appareil pour utiliser le suivi GPS et le partage de localisation.'; + + @override + String get openSettings => 'Ouvrir les paramètres'; + + @override + String get locationPermissionGranted => + 'Autorisation de localisation accordée !'; + + @override + String get locationPermissionRequiredForGps => + 'L\'autorisation de localisation est nécessaire pour le suivi GPS et le partage de localisation.'; + + @override + String get locationPermissionAlreadyGranted => + 'L\'autorisation de localisation est déjà accordée.'; + + @override + String get sarNavyBlue => 'SAR Bleu Marine'; + + @override + String get sarNavyBlueDescription => 'Mode Professionnel/Opérations'; + + @override + String get selectRecipient => 'Sélectionner le destinataire'; + + @override + String get broadcastToAllNearby => 'Diffuser à tous à proximité'; + + @override + String get searchRecipients => 'Rechercher des destinataires...'; + + @override + String get noContactsFound => 'Aucun contact trouvé'; + + @override + String get noRoomsFound => 'Aucune salle trouvée'; + + @override + String get noContactsOrRoomsAvailable => 'Aucun contact ou salle disponible'; + + @override + String get messagesWillBeSentToPublicChannel => + 'Les messages seront envoyés au canal public'; + + @override + String get newMessage => 'Nouveau message'; + + @override + String get channel => 'Canal'; } diff --git a/lib/l10n/app_localizations_hr.dart b/lib/l10n/app_localizations_hr.dart index f8c5871..7315f79 100644 --- a/lib/l10n/app_localizations_hr.dart +++ b/lib/l10n/app_localizations_hr.dart @@ -129,6 +129,13 @@ class AppLocalizationsHr extends AppLocalizations { String get displayPacketActivity => 'Prikaži indikatore aktivnosti paketa u gornjoj traci'; + @override + String get simpleMode => 'Jednostavni način'; + + @override + String get simpleModeDescription => + 'Sakrij nevažne informacije u porukama i kontaktima'; + @override String get language => 'Jezik'; @@ -305,7 +312,7 @@ class AppLocalizationsHr extends AppLocalizations { String get deleteContact => 'Izbriši kontakt'; @override - String get delete => 'Izbriši'; + String get delete => 'Obriši'; @override String get viewOnMap => 'Prikaži na karti'; @@ -314,7 +321,7 @@ class AppLocalizationsHr extends AppLocalizations { String get refresh => 'Osvježi'; @override - String get sendDirectMessage => 'Pošalji izravnu poruku'; + String get sendDirectMessage => 'Pošalji'; @override String get resetPath => 'Resetiraj put (preusmjeri)'; @@ -382,6 +389,24 @@ class AppLocalizationsHr extends AppLocalizations { @override String get clearAllDrawings => 'Očisti sve crteže'; + @override + String get showReceivedDrawings => 'Prikaži primljene crteže'; + + @override + String get showingAllDrawings => 'Prikazujem sve crteže'; + + @override + String get showingOnlyYourDrawings => 'Prikazujem samo vaše crteže'; + + @override + String get showSarMarkers => 'Prikaži SAR oznake'; + + @override + String get showingSarMarkers => 'Prikazujem SAR oznake'; + + @override + String get hidingSarMarkers => 'Skrivam SAR oznake'; + @override String get clearAll => 'Očisti sve'; @@ -457,6 +482,9 @@ class AppLocalizationsHr extends AppLocalizations { @override String get location => 'Lokacija'; + @override + String get myLocation => 'Moja lokacija'; + @override String get fromMap => 'S karte'; @@ -660,6 +688,12 @@ class AppLocalizationsHr extends AppLocalizations { @override String get accuracy => 'Točnost'; + @override + String get bearing => 'Azimut'; + + @override + String get direction => 'Smjer'; + @override String get filterMarkers => 'Filtriraj markere'; @@ -900,6 +934,15 @@ class AppLocalizationsHr extends AppLocalizations { @override String get esriSatellite => 'ESRI satelit'; + @override + String get googleHybrid => 'Google hibridno'; + + @override + String get googleRoadmap => 'Google cestovna karta'; + + @override + String get googleTerrain => 'Google teren'; + @override String get downloadVisibleArea => 'Preuzmi vidljivo područje'; @@ -980,6 +1023,16 @@ class AppLocalizationsHr extends AppLocalizations { @override String get messageDeleted => 'Poruka izbrisana'; + @override + String get copyText => 'Kopiraj tekst'; + + @override + String get deleteMessage => 'Izbriši poruku'; + + @override + String get deleteMessageConfirmation => + 'Jeste li sigurni da želite izbrisati ovu poruku?'; + @override String get refreshedContacts => 'Kontakti osvježeni'; @@ -1113,6 +1166,67 @@ class AppLocalizationsHr extends AppLocalizations { @override String get failedToDeleteMbtiles => 'Neuspjelo brisanje offline karte'; + @override + String get importExportCachedTiles => 'Uvoz/Izvoz predmemoriranih pločica'; + + @override + String get importExportDescription => + 'Sigurnosno kopirajte, dijelite i vraćajte preuzete pločice karte između uređaja'; + + @override + String get exportTilesToFile => 'Izvezi pločice u datoteku'; + + @override + String get importTilesFromFile => 'Uvezi pločice iz datoteke'; + + @override + String get selectExportLocation => 'Odaberite lokaciju izvoza'; + + @override + String get selectImportFile => 'Odaberite arhivu pločica'; + + @override + String get exportingTiles => 'Izvažanje pločica...'; + + @override + String get importingTiles => 'Uvažanje pločica...'; + + @override + String exportSuccess(int count) { + return 'Uspješno izvezeno $count pločica'; + } + + @override + String importSuccess(int count) { + return 'Uspješno uvezeno $count skladišta'; + } + + @override + String exportFailed(String error) { + return 'Export failed: $error'; + } + + @override + String importFailed(String error) { + return 'Import failed: $error'; + } + + @override + String get exportNote => + 'Stvara komprimiranu arhivsku datoteku (.fmtc) koju možete dijeliti i uvesti na drugim uređajima.'; + + @override + String get importNote => + 'Uvozi pločice karte iz prethodno izvezene arhivske datoteke. Pločice će biti spojene s postojećom predmemorijom.'; + + @override + String get noTilesToExport => 'Nema pločica za izvoz'; + + @override + String archiveContainsStores(int count) { + return 'Arhiva sadrži $count skladišta'; + } + @override String get vectorTiles => 'Vektorske pločice'; @@ -1251,4 +1365,187 @@ class AppLocalizationsHr extends AppLocalizations { String failedToGetLocation(String error) { return 'Neuspjelo dohvaćanje lokacije: $error'; } + + @override + String get sarTemplates => 'SAR Templates'; + + @override + String get manageSarTemplates => 'Manage cursor on target templates'; + + @override + String get addTemplate => 'Add Template'; + + @override + String get editTemplate => 'Edit Template'; + + @override + String get deleteTemplate => 'Delete Template'; + + @override + String get templateName => 'Template Name'; + + @override + String get templateNameHint => 'e.g. Found Person'; + + @override + String get templateEmoji => 'Emoji'; + + @override + String get emojiRequired => 'Emoji je obavezan'; + + @override + String get nameRequired => 'Ime je obavezno'; + + @override + String get templateDescription => 'Description (Optional)'; + + @override + String get templateDescriptionHint => 'Add additional context...'; + + @override + String get templateColor => 'Color'; + + @override + String get previewFormat => 'Preview (SAR Message Format)'; + + @override + String get importFromClipboard => 'Import'; + + @override + String get exportToClipboard => 'Export'; + + @override + String deleteTemplateConfirmation(String name) { + return 'Delete template \'$name\'?'; + } + + @override + String get templateAdded => 'Template added'; + + @override + String get templateUpdated => 'Template updated'; + + @override + String get templateDeleted => 'Template deleted'; + + @override + String templatesImported(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Imported $count templates', + one: 'Imported 1 template', + zero: 'No templates imported', + ); + return '$_temp0'; + } + + @override + String templatesExported(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Exported $count templates to clipboard', + one: 'Exported 1 template to clipboard', + ); + return '$_temp0'; + } + + @override + String get resetToDefaults => 'Reset to Defaults'; + + @override + String get resetToDefaultsConfirmation => + 'This will delete all custom templates and restore the 4 default templates. Continue?'; + + @override + String get reset => 'Reset'; + + @override + String get resetComplete => 'Templates reset to defaults'; + + @override + String get noTemplates => 'No templates available'; + + @override + String get tapAddToCreate => 'Tap + to create your first template'; + + @override + String get ok => 'OK'; + + @override + String get permissionsSection => 'Dozvole'; + + @override + String get locationPermission => 'Dozvola za lokaciju'; + + @override + String get checking => 'Provjera...'; + + @override + String get locationPermissionGrantedAlways => 'Odobreno (Uvijek)'; + + @override + String get locationPermissionGrantedWhileInUse => + 'Odobreno (Tijekom uporabe)'; + + @override + String get locationPermissionDeniedTapToRequest => + 'Odbijeno - Dodirnite za zahtjev'; + + @override + String get locationPermissionPermanentlyDeniedOpenSettings => + 'Trajno odbijeno - Otvori postavke'; + + @override + String get locationPermissionDialogContent => + 'Dozvola za lokaciju je trajno odbijena. Omogućite je u postavkama uređaja kako biste koristili GPS praćenje i dijeljenje lokacije.'; + + @override + String get openSettings => 'Otvori postavke'; + + @override + String get locationPermissionGranted => 'Dozvola za lokaciju odobrena!'; + + @override + String get locationPermissionRequiredForGps => + 'Dozvola za lokaciju je potrebna za GPS praćenje i dijeljenje lokacije.'; + + @override + String get locationPermissionAlreadyGranted => + 'Dozvola za lokaciju je već odobrena.'; + + @override + String get sarNavyBlue => 'SAR Mornarsko Plava'; + + @override + String get sarNavyBlueDescription => 'Profesionalni/Operativni Način'; + + @override + String get selectRecipient => 'Odaberi primatelja'; + + @override + String get broadcastToAllNearby => 'Emituj svima u blizini'; + + @override + String get searchRecipients => 'Pretraži primatelje...'; + + @override + String get noContactsFound => 'Nema kontakata'; + + @override + String get noRoomsFound => 'Nema soba'; + + @override + String get noContactsOrRoomsAvailable => 'Nema dostupnih kontakata ili soba'; + + @override + String get messagesWillBeSentToPublicChannel => + 'Poruke će biti poslane na javni kanal'; + + @override + String get newMessage => 'Nova poruka'; + + @override + String get channel => 'Kanal'; } diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index 0b03527..3e48b22 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -129,6 +129,13 @@ class AppLocalizationsIt extends AppLocalizations { String get displayPacketActivity => 'Mostra indicatori di attività pacchetti nella barra superiore'; + @override + String get simpleMode => 'Modalità Semplice'; + + @override + String get simpleModeDescription => + 'Nascondi informazioni non essenziali nei messaggi e contatti'; + @override String get language => 'Lingua'; @@ -316,7 +323,7 @@ class AppLocalizationsIt extends AppLocalizations { String get refresh => 'Aggiorna'; @override - String get sendDirectMessage => 'Invia Messaggio Diretto'; + String get sendDirectMessage => 'Invia'; @override String get resetPath => 'Resetta Percorso (Ri-instrada)'; @@ -384,6 +391,24 @@ class AppLocalizationsIt extends AppLocalizations { @override String get clearAllDrawings => 'Cancella Tutti i Disegni'; + @override + String get showReceivedDrawings => 'Mostra Disegni Ricevuti'; + + @override + String get showingAllDrawings => 'Visualizzazione di tutti i disegni'; + + @override + String get showingOnlyYourDrawings => 'Visualizzazione solo dei tuoi disegni'; + + @override + String get showSarMarkers => 'Mostra marcatori SAR'; + + @override + String get showingSarMarkers => 'Visualizzazione marcatori SAR'; + + @override + String get hidingSarMarkers => 'Nascondere marcatori SAR'; + @override String get clearAll => 'Cancella Tutto'; @@ -459,6 +484,9 @@ class AppLocalizationsIt extends AppLocalizations { @override String get location => 'Posizione'; + @override + String get myLocation => 'La mia posizione'; + @override String get fromMap => 'Dalla Mappa'; @@ -662,6 +690,12 @@ class AppLocalizationsIt extends AppLocalizations { @override String get accuracy => 'Precisione'; + @override + String get bearing => 'Rilevamento'; + + @override + String get direction => 'Direzione'; + @override String get filterMarkers => 'Filtra Marcatori'; @@ -905,6 +939,15 @@ class AppLocalizationsIt extends AppLocalizations { @override String get esriSatellite => 'ESRI Satellite'; + @override + String get googleHybrid => 'Google Ibrido'; + + @override + String get googleRoadmap => 'Google Mappa Stradale'; + + @override + String get googleTerrain => 'Google Terreno'; + @override String get downloadVisibleArea => 'Scarica area visibile'; @@ -986,6 +1029,16 @@ class AppLocalizationsIt extends AppLocalizations { @override String get messageDeleted => 'Messaggio eliminato'; + @override + String get copyText => 'Copia testo'; + + @override + String get deleteMessage => 'Elimina messaggio'; + + @override + String get deleteMessageConfirmation => + 'Sei sicuro di voler eliminare questo messaggio?'; + @override String get refreshedContacts => 'Contatti aggiornati'; @@ -1121,6 +1174,67 @@ class AppLocalizationsIt extends AppLocalizations { @override String get failedToDeleteMbtiles => 'Impossibile eliminare la mappa offline'; + @override + String get importExportCachedTiles => 'Importa/Esporta tile in cache'; + + @override + String get importExportDescription => + 'Esegui backup, condividi e ripristina tile mappa scaricati tra dispositivi'; + + @override + String get exportTilesToFile => 'Esporta tile su file'; + + @override + String get importTilesFromFile => 'Importa tile da file'; + + @override + String get selectExportLocation => 'Seleziona posizione esportazione'; + + @override + String get selectImportFile => 'Seleziona archivio tile'; + + @override + String get exportingTiles => 'Esportazione tile...'; + + @override + String get importingTiles => 'Importazione tile...'; + + @override + String exportSuccess(int count) { + return '$count tile esportati con successo'; + } + + @override + String importSuccess(int count) { + return '$count archivi importati con successo'; + } + + @override + String exportFailed(String error) { + return 'Export failed: $error'; + } + + @override + String importFailed(String error) { + return 'Import failed: $error'; + } + + @override + String get exportNote => + 'Crea un file archivio compresso (.fmtc) che può essere condiviso e importato su altri dispositivi.'; + + @override + String get importNote => + 'Importa tile mappa da un file archivio precedentemente esportato. I tile verranno uniti con la cache esistente.'; + + @override + String get noTilesToExport => 'Nessun tile da esportare'; + + @override + String archiveContainsStores(int count) { + return 'L\'archivio contiene $count archivi'; + } + @override String get vectorTiles => 'Tile Vettoriali'; @@ -1261,4 +1375,187 @@ class AppLocalizationsIt extends AppLocalizations { String failedToGetLocation(String error) { return 'Impossibile ottenere la posizione: $error'; } + + @override + String get sarTemplates => 'SAR Templates'; + + @override + String get manageSarTemplates => 'Manage cursor on target templates'; + + @override + String get addTemplate => 'Add Template'; + + @override + String get editTemplate => 'Edit Template'; + + @override + String get deleteTemplate => 'Delete Template'; + + @override + String get templateName => 'Template Name'; + + @override + String get templateNameHint => 'e.g. Found Person'; + + @override + String get templateEmoji => 'Emoji'; + + @override + String get emojiRequired => 'Emoji è obbligatorio'; + + @override + String get nameRequired => 'Nome è obbligatorio'; + + @override + String get templateDescription => 'Description (Optional)'; + + @override + String get templateDescriptionHint => 'Add additional context...'; + + @override + String get templateColor => 'Color'; + + @override + String get previewFormat => 'Preview (SAR Message Format)'; + + @override + String get importFromClipboard => 'Import'; + + @override + String get exportToClipboard => 'Export'; + + @override + String deleteTemplateConfirmation(String name) { + return 'Delete template \'$name\'?'; + } + + @override + String get templateAdded => 'Template added'; + + @override + String get templateUpdated => 'Template updated'; + + @override + String get templateDeleted => 'Template deleted'; + + @override + String templatesImported(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Imported $count templates', + one: 'Imported 1 template', + zero: 'No templates imported', + ); + return '$_temp0'; + } + + @override + String templatesExported(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Exported $count templates to clipboard', + one: 'Exported 1 template to clipboard', + ); + return '$_temp0'; + } + + @override + String get resetToDefaults => 'Reset to Defaults'; + + @override + String get resetToDefaultsConfirmation => + 'This will delete all custom templates and restore the 4 default templates. Continue?'; + + @override + String get reset => 'Reset'; + + @override + String get resetComplete => 'Templates reset to defaults'; + + @override + String get noTemplates => 'No templates available'; + + @override + String get tapAddToCreate => 'Tap + to create your first template'; + + @override + String get ok => 'OK'; + + @override + String get permissionsSection => 'Permessi'; + + @override + String get locationPermission => 'Permesso di posizione'; + + @override + String get checking => 'Verifica in corso...'; + + @override + String get locationPermissionGrantedAlways => 'Concesso (Sempre)'; + + @override + String get locationPermissionGrantedWhileInUse => 'Concesso (Durante l\'uso)'; + + @override + String get locationPermissionDeniedTapToRequest => + 'Negato - Tocca per richiedere'; + + @override + String get locationPermissionPermanentlyDeniedOpenSettings => + 'Negato permanentemente - Apri impostazioni'; + + @override + String get locationPermissionDialogContent => + 'Il permesso di posizione è permanentemente negato. Si prega di abilitarlo nelle impostazioni del dispositivo per utilizzare il tracciamento GPS e la condivisione della posizione.'; + + @override + String get openSettings => 'Apri impostazioni'; + + @override + String get locationPermissionGranted => 'Permesso di posizione concesso!'; + + @override + String get locationPermissionRequiredForGps => + 'Il permesso di posizione è necessario per il tracciamento GPS e la condivisione della posizione.'; + + @override + String get locationPermissionAlreadyGranted => + 'Il permesso di posizione è già concesso.'; + + @override + String get sarNavyBlue => 'SAR Blu Navy'; + + @override + String get sarNavyBlueDescription => 'Modalità Professionale/Operativa'; + + @override + String get selectRecipient => 'Seleziona destinatario'; + + @override + String get broadcastToAllNearby => 'Trasmetti a tutti nelle vicinanze'; + + @override + String get searchRecipients => 'Cerca destinatari...'; + + @override + String get noContactsFound => 'Nessun contatto trovato'; + + @override + String get noRoomsFound => 'Nessuna stanza trovata'; + + @override + String get noContactsOrRoomsAvailable => + 'Nessun contatto o stanza disponibile'; + + @override + String get messagesWillBeSentToPublicChannel => + 'I messaggi saranno inviati al canale pubblico'; + + @override + String get newMessage => 'Nuovo messaggio'; + + @override + String get channel => 'Canale'; } diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index f7d727a..6c64fdb 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -129,6 +129,13 @@ class AppLocalizationsSl extends AppLocalizations { String get displayPacketActivity => 'Prikaži kazalnike aktivnosti paketov v zgornji vrstici'; + @override + String get simpleMode => 'Preprost način'; + + @override + String get simpleModeDescription => + 'Skrij nepomembne informacije v sporočilih in kontaktih'; + @override String get language => 'Jezik'; @@ -314,7 +321,7 @@ class AppLocalizationsSl extends AppLocalizations { String get refresh => 'Osveži'; @override - String get sendDirectMessage => 'Pošlji neposredno sporočilo'; + String get sendDirectMessage => 'Pošlji'; @override String get resetPath => 'Ponastavi pot (preusmeri)'; @@ -382,6 +389,24 @@ class AppLocalizationsSl extends AppLocalizations { @override String get clearAllDrawings => 'Počisti vse risbe'; + @override + String get showReceivedDrawings => 'Prikaži prejete risbe'; + + @override + String get showingAllDrawings => 'Prikazujem vse risbe'; + + @override + String get showingOnlyYourDrawings => 'Prikazujem samo vaše risbe'; + + @override + String get showSarMarkers => 'Prikaži SAR označevalce'; + + @override + String get showingSarMarkers => 'Prikazujem SAR označevalce'; + + @override + String get hidingSarMarkers => 'Skrivam SAR označevalce'; + @override String get clearAll => 'Počisti vse'; @@ -457,6 +482,9 @@ class AppLocalizationsSl extends AppLocalizations { @override String get location => 'Lokacija'; + @override + String get myLocation => 'Moja lokacija'; + @override String get fromMap => 'Z zemljevida'; @@ -660,6 +688,12 @@ class AppLocalizationsSl extends AppLocalizations { @override String get accuracy => 'Natančnost'; + @override + String get bearing => 'Azimut'; + + @override + String get direction => 'Smer'; + @override String get filterMarkers => 'Filtriraj označevalce'; @@ -900,6 +934,15 @@ class AppLocalizationsSl extends AppLocalizations { @override String get esriSatellite => 'ESRI satelit'; + @override + String get googleHybrid => 'Google hibridno'; + + @override + String get googleRoadmap => 'Google cestni zemljevid'; + + @override + String get googleTerrain => 'Google teren'; + @override String get downloadVisibleArea => 'Prenesi vidno območje'; @@ -980,6 +1023,16 @@ class AppLocalizationsSl extends AppLocalizations { @override String get messageDeleted => 'Sporočilo izbrisano'; + @override + String get copyText => 'Kopiraj besedilo'; + + @override + String get deleteMessage => 'Izbriši sporočilo'; + + @override + String get deleteMessageConfirmation => + 'Ali ste prepričani, da želite izbrisati to sporočilo?'; + @override String get refreshedContacts => 'Stiki osveženi'; @@ -1116,6 +1169,67 @@ class AppLocalizationsSl extends AppLocalizations { String get failedToDeleteMbtiles => 'Brisanje brezpoveznega zemljevida ni uspelo'; + @override + String get importExportCachedTiles => 'Uvoz/Izvoz predpomnjenih ploščic'; + + @override + String get importExportDescription => + 'Varnostno kopirajte, delite in obnovite prenesene ploščice zemljevida med napravami'; + + @override + String get exportTilesToFile => 'Izvozi ploščice v datoteko'; + + @override + String get importTilesFromFile => 'Uvozi ploščice iz datoteke'; + + @override + String get selectExportLocation => 'Izberi lokacijo izvoza'; + + @override + String get selectImportFile => 'Izberi arhiv ploščic'; + + @override + String get exportingTiles => 'Izvažanje ploščic...'; + + @override + String get importingTiles => 'Uvažanje ploščic...'; + + @override + String exportSuccess(int count) { + return 'Uspešno izvoženih $count ploščic'; + } + + @override + String importSuccess(int count) { + return 'Uspešno uvoženih $count skladišč'; + } + + @override + String exportFailed(String error) { + return 'Export failed: $error'; + } + + @override + String importFailed(String error) { + return 'Import failed: $error'; + } + + @override + String get exportNote => + 'Ustvari stisnjeno arhivsko datoteko (.fmtc), ki jo lahko delite in uvozite na drugih napravah.'; + + @override + String get importNote => + 'Uvozi ploščice zemljevida iz predhodno izvožene arhivske datoteke. Ploščice bodo združene z obstoječim predpomnilnikom.'; + + @override + String get noTilesToExport => 'Ni ploščic za izvoz'; + + @override + String archiveContainsStores(int count) { + return 'Arhiv vsebuje $count skladišč'; + } + @override String get vectorTiles => 'Vektorske ploščice'; @@ -1254,4 +1368,186 @@ class AppLocalizationsSl extends AppLocalizations { String failedToGetLocation(String error) { return 'Pridobivanje lokacije ni uspelo: $error'; } + + @override + String get sarTemplates => 'SAR Templates'; + + @override + String get manageSarTemplates => 'Manage cursor on target templates'; + + @override + String get addTemplate => 'Add Template'; + + @override + String get editTemplate => 'Edit Template'; + + @override + String get deleteTemplate => 'Delete Template'; + + @override + String get templateName => 'Template Name'; + + @override + String get templateNameHint => 'e.g. Found Person'; + + @override + String get templateEmoji => 'Emoji'; + + @override + String get emojiRequired => 'Emoji je obvezen'; + + @override + String get nameRequired => 'Ime je obvezno'; + + @override + String get templateDescription => 'Description (Optional)'; + + @override + String get templateDescriptionHint => 'Add additional context...'; + + @override + String get templateColor => 'Color'; + + @override + String get previewFormat => 'Preview (SAR Message Format)'; + + @override + String get importFromClipboard => 'Import'; + + @override + String get exportToClipboard => 'Export'; + + @override + String deleteTemplateConfirmation(String name) { + return 'Delete template \'$name\'?'; + } + + @override + String get templateAdded => 'Template added'; + + @override + String get templateUpdated => 'Template updated'; + + @override + String get templateDeleted => 'Template deleted'; + + @override + String templatesImported(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Imported $count templates', + one: 'Imported 1 template', + zero: 'No templates imported', + ); + return '$_temp0'; + } + + @override + String templatesExported(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Exported $count templates to clipboard', + one: 'Exported 1 template to clipboard', + ); + return '$_temp0'; + } + + @override + String get resetToDefaults => 'Reset to Defaults'; + + @override + String get resetToDefaultsConfirmation => + 'This will delete all custom templates and restore the 4 default templates. Continue?'; + + @override + String get reset => 'Reset'; + + @override + String get resetComplete => 'Templates reset to defaults'; + + @override + String get noTemplates => 'No templates available'; + + @override + String get tapAddToCreate => 'Tap + to create your first template'; + + @override + String get ok => 'OK'; + + @override + String get permissionsSection => 'Dovoljenja'; + + @override + String get locationPermission => 'Dovoljenje za lokacijo'; + + @override + String get checking => 'Preverjanje...'; + + @override + String get locationPermissionGrantedAlways => 'Odobreno (Vedno)'; + + @override + String get locationPermissionGrantedWhileInUse => 'Odobreno (Med uporabo)'; + + @override + String get locationPermissionDeniedTapToRequest => + 'Zavrnjeno - Tapnite za zahtevo'; + + @override + String get locationPermissionPermanentlyDeniedOpenSettings => + 'Trajno zavrnjeno - Odpri nastavitve'; + + @override + String get locationPermissionDialogContent => + 'Dovoljenje za lokacijo je trajno zavrnjeno. Omogočite ga v nastavitvah naprave za uporabo sledenja GPS in deljenja lokacije.'; + + @override + String get openSettings => 'Odpri nastavitve'; + + @override + String get locationPermissionGranted => 'Dovoljenje za lokacijo odobreno!'; + + @override + String get locationPermissionRequiredForGps => + 'Dovoljenje za lokacijo je potrebno za sledenje GPS in deljenje lokacije.'; + + @override + String get locationPermissionAlreadyGranted => + 'Dovoljenje za lokacijo je že odobreno.'; + + @override + String get sarNavyBlue => 'SAR Mornarska Modra'; + + @override + String get sarNavyBlueDescription => 'Profesionalni/Operativni Način'; + + @override + String get selectRecipient => 'Izberi prejemnika'; + + @override + String get broadcastToAllNearby => 'Oddajaj vsem v bližini'; + + @override + String get searchRecipients => 'Išči prejemnike...'; + + @override + String get noContactsFound => 'Ni kontaktov'; + + @override + String get noRoomsFound => 'Ni sob'; + + @override + String get noContactsOrRoomsAvailable => 'Ni na voljo kontaktov ali sob'; + + @override + String get messagesWillBeSentToPublicChannel => + 'Sporočila bodo poslana na javni kanal'; + + @override + String get newMessage => 'Novo sporočilo'; + + @override + String get channel => 'Kanal'; } diff --git a/lib/l10n/app_sl.arb b/lib/l10n/app_sl.arb index b8c443f..709d751 100644 --- a/lib/l10n/app_sl.arb +++ b/lib/l10n/app_sl.arb @@ -75,6 +75,10 @@ "displayPacketActivity": "Prikaži kazalnike aktivnosti paketov v zgornji vrstici", + "simpleMode": "Preprost način", + + "simpleModeDescription": "Skrij nepomembne informacije v sporočilih in kontaktih", + "language": "Jezik", "chooseLanguage": "Izberite jezik", @@ -181,7 +185,7 @@ "refresh": "Osveži", - "sendDirectMessage": "Pošlji neposredno sporočilo", + "sendDirectMessage": "Pošlji", "resetPath": "Ponastavi pot (preusmeri)", @@ -221,6 +225,18 @@ "clearAllDrawings": "Počisti vse risbe", + "showReceivedDrawings": "Prikaži prejete risbe", + + "showingAllDrawings": "Prikazujem vse risbe", + + "showingOnlyYourDrawings": "Prikazujem samo vaše risbe", + + "showSarMarkers": "Prikaži SAR označevalce", + + "showingSarMarkers": "Prikazujem SAR označevalce", + + "hidingSarMarkers": "Skrivam SAR označevalce", + "clearAll": "Počisti vse", "noLocalDrawings": "Ni lokalnih risb za deljenje", @@ -263,6 +279,8 @@ "location": "Lokacija", + "myLocation": "Moja lokacija", + "fromMap": "Z zemljevida", "gettingLocation": "Pridobivanje lokacije...", @@ -379,6 +397,12 @@ "accuracy": "Natančnost", + "distance": "Razdalja", + + "bearing": "Azimut", + + "direction": "Smer", + "filterMarkers": "Filtriraj označevalce", "filterMarkersTooltip": "Filtriraj označevalce", @@ -521,6 +545,12 @@ "esriSatellite": "ESRI satelit", + "googleHybrid": "Google hibridno", + + "googleRoadmap": "Google cestni zemljevid", + + "googleTerrain": "Google teren", + "downloadVisibleArea": "Prenesi vidno območje", "initializingMap": "Inicializacija zemljevida...", @@ -586,6 +616,10 @@ "cannotReplyContactNotFound": "Ni mogoče odgovoriti: stik ni najden", "messageDeleted": "Sporočilo izbrisano", + "copyText": "Kopiraj besedilo", + "textCopiedToClipboard": "Besedilo kopirano v odložišče", + "deleteMessage": "Izbriši sporočilo", + "deleteMessageConfirmation": "Ali ste prepričani, da želite izbrisati to sporočilo?", "refreshedContacts": "Stiki osveženi", @@ -636,6 +670,38 @@ "failedToDeleteMbtiles": "Brisanje brezpoveznega zemljevida ni uspelo", + "importExportCachedTiles": "Uvoz/Izvoz predpomnjenih ploščic", + + "importExportDescription": "Varnostno kopirajte, delite in obnovite prenesene ploščice zemljevida med napravami", + + "exportTilesToFile": "Izvozi ploščice v datoteko", + + "importTilesFromFile": "Uvozi ploščice iz datoteke", + + "selectExportLocation": "Izberi lokacijo izvoza", + + "selectImportFile": "Izberi arhiv ploščic", + + "exportingTiles": "Izvažanje ploščic...", + + "importingTiles": "Uvažanje ploščic...", + + "exportSuccess": "Uspešno izvoženih {count} ploščic", + + "importSuccess": "Uspešno uvoženih {count} skladišč", + + "exportFailed": "Izvoz ni uspel: {error}", + + "importFailed": "Uvoz ni uspel: {error}", + + "exportNote": "Ustvari stisnjeno arhivsko datoteko (.fmtc), ki jo lahko delite in uvozite na drugih napravah.", + + "importNote": "Uvozi ploščice zemljevida iz predhodno izvožene arhivske datoteke. Ploščice bodo združene z obstoječim predpomnilnikom.", + + "noTilesToExport": "Ni ploščic za izvoz", + + "archiveContainsStores": "Arhiv vsebuje {count} skladišč", + "vectorTiles": "Vektorske ploščice", "schema": "Shema", @@ -722,5 +788,63 @@ "failedToSave": "Shranjevanje ni uspelo: {error}", - "failedToGetLocation": "Pridobivanje lokacije ni uspelo: {error}" + "failedToGetLocation": "Pridobivanje lokacije ni uspelo: {error}", + + "sarTemplates": "SAR Templates", + "manageSarTemplates": "Manage cursor on target templates", + "addTemplate": "Add Template", + "editTemplate": "Edit Template", + "deleteTemplate": "Delete Template", + "templateName": "Template Name", + "templateNameHint": "e.g. Found Person", + "templateEmoji": "Emoji", + "emojiRequired": "Emoji je obvezen", + "nameRequired": "Ime je obvezno", + "templateDescription": "Description (Optional)", + "templateDescriptionHint": "Add additional context...", + "templateColor": "Color", + "previewFormat": "Preview (SAR Message Format)", + "importFromClipboard": "Import", + "exportToClipboard": "Export", + "deleteTemplateConfirmation": "Delete template '{name}'?", + "templateAdded": "Template added", + "templateUpdated": "Template updated", + "templateDeleted": "Template deleted", + "templatesImported": "{count, plural, =0{No templates imported} =1{Imported 1 template} other{Imported {count} templates}}", + "templatesExported": "{count, plural, =1{Exported 1 template to clipboard} other{Exported {count} templates to clipboard}}", + "importFailed": "Import failed: {error}", + "exportFailed": "Export failed: {error}", + "resetToDefaults": "Reset to Defaults", + "resetToDefaultsConfirmation": "This will delete all custom templates and restore the 4 default templates. Continue?", + "reset": "Reset", + "resetComplete": "Templates reset to defaults", + "noTemplates": "No templates available", + "tapAddToCreate": "Tap + to create your first template", + "ok": "OK", + "delete": "Izbriši", + + "permissionsSection": "Dovoljenja", + "locationPermission": "Dovoljenje za lokacijo", + "checking": "Preverjanje...", + "locationPermissionGrantedAlways": "Odobreno (Vedno)", + "locationPermissionGrantedWhileInUse": "Odobreno (Med uporabo)", + "locationPermissionDeniedTapToRequest": "Zavrnjeno - Tapnite za zahtevo", + "locationPermissionPermanentlyDeniedOpenSettings": "Trajno zavrnjeno - Odpri nastavitve", + "locationPermissionDialogContent": "Dovoljenje za lokacijo je trajno zavrnjeno. Omogočite ga v nastavitvah naprave za uporabo sledenja GPS in deljenja lokacije.", + "openSettings": "Odpri nastavitve", + "locationPermissionGranted": "Dovoljenje za lokacijo odobreno!", + "locationPermissionRequiredForGps": "Dovoljenje za lokacijo je potrebno za sledenje GPS in deljenje lokacije.", + "locationPermissionAlreadyGranted": "Dovoljenje za lokacijo je že odobreno.", + "sarNavyBlue": "SAR Mornarska Modra", + "sarNavyBlueDescription": "Profesionalni/Operativni Način", + + "selectRecipient": "Izberi prejemnika", + "broadcastToAllNearby": "Oddajaj vsem v bližini", + "searchRecipients": "Išči prejemnike...", + "noContactsFound": "Ni kontaktov", + "noRoomsFound": "Ni sob", + "noContactsOrRoomsAvailable": "Ni na voljo kontaktov ali sob", + "messagesWillBeSentToPublicChannel": "Sporočila bodo poslana na javni kanal", + "newMessage": "Novo sporočilo", + "channel": "Kanal" } diff --git a/lib/main.dart b/lib/main.dart index c13fcc4..0ccbec9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -113,9 +113,11 @@ class _MeshCoreSarAppState extends State { ChangeNotifierProvider(create: (_) => ConnectionProvider()), ChangeNotifierProvider( create: (_) { - // Don't initialize here - it will be initialized in AppProvider.initialize() - // after connection is established and device info is available - return ContactsProvider(); + // Initialize early to load persisted contacts for offline viewing + // Self-contact filtering will happen later when BLE connects + final provider = ContactsProvider(); + provider.initializeEarly(); + return provider; }, ), ChangeNotifierProvider( diff --git a/lib/models/map_drawing.dart b/lib/models/map_drawing.dart index a54ef61..72e4d26 100644 --- a/lib/models/map_drawing.dart +++ b/lib/models/map_drawing.dart @@ -71,6 +71,7 @@ abstract class MapDrawing { final DateTime createdAt; final String? senderName; // Name of sender (null if local drawing) final bool isReceived; // True if drawing was received from another node + final String? messageId; // ID of the source message (for navigation) MapDrawing({ required this.id, @@ -79,6 +80,7 @@ abstract class MapDrawing { required this.createdAt, this.senderName, this.isReceived = false, + this.messageId, }); /// Convert to JSON for persistence @@ -90,8 +92,12 @@ abstract class MapDrawing { Map toNetworkJson(); /// Parse network JSON (compact format) - /// senderName will be populated from packet metadata - static MapDrawing? fromNetworkJson(Map json, {String? senderName}) { + /// senderName and messageId will be populated from packet metadata + static MapDrawing? fromNetworkJson( + Map json, { + String? senderName, + String? messageId, + }) { final typeNum = json['t'] as int?; if (typeNum == null || typeNum < 0 || typeNum >= DrawingShapeType.values.length) { return null; @@ -102,9 +108,17 @@ abstract class MapDrawing { switch (type) { case DrawingShapeType.line: - return LineDrawing.fromNetworkJson(json, senderName: senderName); + return LineDrawing.fromNetworkJson( + json, + senderName: senderName, + messageId: messageId, + ); case DrawingShapeType.rectangle: - return RectangleDrawing.fromNetworkJson(json, senderName: senderName); + return RectangleDrawing.fromNetworkJson( + json, + senderName: senderName, + messageId: messageId, + ); } } catch (e) { return null; @@ -144,6 +158,7 @@ class LineDrawing extends MapDrawing { required this.points, super.senderName, super.isReceived, + super.messageId, }) : super(type: DrawingShapeType.line); @override @@ -184,7 +199,11 @@ class LineDrawing extends MapDrawing { ); } - static LineDrawing fromNetworkJson(Map json, {String? senderName}) { + static LineDrawing fromNetworkJson( + Map json, { + String? senderName, + String? messageId, + }) { // Parse ultra-compact format final pointsFlat = (json['p'] as List).cast(); final points = []; @@ -199,6 +218,7 @@ class LineDrawing extends MapDrawing { points: points, senderName: senderName, isReceived: true, + messageId: messageId, // Link to source message ); } @@ -226,6 +246,7 @@ class RectangleDrawing extends MapDrawing { required this.bottomRight, super.senderName, super.isReceived, + super.messageId, }) : super(type: DrawingShapeType.rectangle); /// Get all corner points for rendering @@ -276,7 +297,11 @@ class RectangleDrawing extends MapDrawing { ); } - static RectangleDrawing fromNetworkJson(Map json, {String? senderName}) { + static RectangleDrawing fromNetworkJson( + Map json, { + String? senderName, + String? messageId, + }) { // Parse ultra-compact format final bounds = (json['b'] as List).cast(); @@ -288,6 +313,7 @@ class RectangleDrawing extends MapDrawing { bottomRight: LatLng(bounds[2], bounds[3]), senderName: senderName, isReceived: true, + messageId: messageId, // Link to source message ); } diff --git a/lib/models/map_layer.dart b/lib/models/map_layer.dart index 20cbf39..0f2a7db 100644 --- a/lib/models/map_layer.dart +++ b/lib/models/map_layer.dart @@ -6,6 +6,9 @@ enum MapLayerType { openStreetMap, openTopoMap, esriWorldImagery, + googleHybrid, + googleRoadmap, + googleTerrain, vectorMbtiles, } @@ -46,6 +49,12 @@ class MapLayer { return localizations.openTopoMap; case MapLayerType.esriWorldImagery: return localizations.esriSatellite; + case MapLayerType.googleHybrid: + return localizations.googleHybrid; + case MapLayerType.googleRoadmap: + return localizations.googleRoadmap; + case MapLayerType.googleTerrain: + return localizations.googleTerrain; case MapLayerType.vectorMbtiles: // For vector tiles, use the name from metadata return name; @@ -77,10 +86,37 @@ class MapLayer { maxZoom: 19, // ESRI World Imagery maximum ); + static const googleHybrid = MapLayer( + type: MapLayerType.googleHybrid, + name: 'Google Hybrid', + urlTemplate: 'http://mt0.google.com/vt/lyrs=y&hl=en&x={x}&y={y}&z={z}', + attribution: '© Google', + maxZoom: 20, // Google Maps maximum + ); + + static const googleRoadmap = MapLayer( + type: MapLayerType.googleRoadmap, + name: 'Google Roadmap', + urlTemplate: 'http://mt0.google.com/vt/lyrs=m&hl=en&x={x}&y={y}&z={z}', + attribution: '© Google', + maxZoom: 20, // Google Maps maximum + ); + + static const googleTerrain = MapLayer( + type: MapLayerType.googleTerrain, + name: 'Google Terrain', + urlTemplate: 'http://mt0.google.com/vt/lyrs=p&hl=en&x={x}&y={y}&z={z}', + attribution: '© Google', + maxZoom: 20, // Google Maps maximum + ); + static const List allLayers = [ openStreetMap, openTopoMap, esriWorldImagery, + googleHybrid, + googleRoadmap, + googleTerrain, ]; static MapLayer fromType(MapLayerType type) { diff --git a/lib/models/message.dart b/lib/models/message.dart index 905b1f1..51bb8ee 100644 --- a/lib/models/message.dart +++ b/lib/models/message.dart @@ -1,4 +1,5 @@ import 'dart:typed_data'; +import 'package:flutter/foundation.dart'; import 'package:latlong2/latlong.dart'; import 'sar_marker.dart'; @@ -51,6 +52,7 @@ class Message { final SarMarkerType? sarMarkerType; final LatLng? sarGpsCoordinates; final String? sarNotes; // Optional message/notes for SAR marker + final String? sarCustomEmoji; // Custom emoji for unknown SAR marker types // Display metadata final DateTime receivedAt; @@ -89,6 +91,7 @@ class Message { this.sarMarkerType, this.sarGpsCoordinates, this.sarNotes, + this.sarCustomEmoji, required this.receivedAt, this.senderName, this.deliveryStatus = MessageDeliveryStatus.received, @@ -171,6 +174,13 @@ class Message { return null; } + // Debug: Check what's in sarNotes + debugPrint('📍 [Message.toSarMarker] Converting to marker:'); + debugPrint(' message.text: "${text}"'); + debugPrint(' message.sarNotes: "${sarNotes}"'); + debugPrint(' message.sarMarkerType: ${sarMarkerType}'); + debugPrint(' message.sarCustomEmoji: "${sarCustomEmoji}"'); + return SarMarker( id: id, type: sarMarkerType!, @@ -179,6 +189,7 @@ class Message { senderPublicKey: senderPublicKeyPrefix, senderName: senderName, notes: sarNotes, // Use dedicated notes field instead of full text + customEmoji: sarCustomEmoji, // Preserve custom emoji for unknown types ); } @@ -275,6 +286,7 @@ class Message { SarMarkerType? sarMarkerType, LatLng? sarGpsCoordinates, String? sarNotes, + String? sarCustomEmoji, DateTime? receivedAt, String? senderName, MessageDeliveryStatus? deliveryStatus, @@ -303,6 +315,7 @@ class Message { sarMarkerType: sarMarkerType ?? this.sarMarkerType, sarGpsCoordinates: sarGpsCoordinates ?? this.sarGpsCoordinates, sarNotes: sarNotes ?? this.sarNotes, + sarCustomEmoji: sarCustomEmoji ?? this.sarCustomEmoji, receivedAt: receivedAt ?? this.receivedAt, senderName: senderName ?? this.senderName, deliveryStatus: deliveryStatus ?? this.deliveryStatus, diff --git a/lib/models/sar_marker.dart b/lib/models/sar_marker.dart index 61e5743..3a9dc97 100644 --- a/lib/models/sar_marker.dart +++ b/lib/models/sar_marker.dart @@ -2,6 +2,7 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:latlong2/latlong.dart'; import '../l10n/app_localizations.dart'; +import '../services/sar_template_service.dart'; /// SAR (Search & Rescue) marker types enum SarMarkerType { @@ -75,6 +76,7 @@ class SarMarker { final Uint8List? senderPublicKey; final String? senderName; final String? notes; + final String? customEmoji; // For custom SAR markers not in predefined types SarMarker({ required this.id, @@ -84,6 +86,7 @@ class SarMarker { this.senderPublicKey, this.senderName, this.notes, + this.customEmoji, }); /// Get sender public key as hex string (short) @@ -109,9 +112,47 @@ class SarMarker { return DateTime.now().difference(timestamp).inHours < 1; } - /// Get display name + /// Get the emoji to display (custom emoji if available, otherwise type emoji) + String get emoji { + return customEmoji ?? type.emoji; + } + + /// Get display name - uses notes if available, otherwise looks up template by emoji, otherwise type name String get displayName { - return '${type.emoji} ${type.displayName}'; + if (notes != null && notes!.isNotEmpty) { + return notes!; + } + + // If no notes and we have a custom emoji, try to look up the template + if (customEmoji != null) { + // Import the service here to avoid circular dependencies + // We'll use a static lookup method + return _lookupTemplateNameByEmoji(customEmoji!) ?? type.displayName; + } + + return type.displayName; + } + + /// Look up template name by emoji from SarTemplateService + static String? _lookupTemplateNameByEmoji(String emoji) { + try { + // Use the singleton instance + final service = SarTemplateService(); + if (!service.isInitialized) { + return null; + } + + // Find template with matching emoji + final template = service.templates.firstWhere( + (t) => t.emoji == emoji, + orElse: () => throw StateError('No template found'), + ); + + return template.name; + } catch (e) { + // Template not found or service not initialized + return null; + } } SarMarker copyWith({ @@ -122,6 +163,7 @@ class SarMarker { Uint8List? senderPublicKey, String? senderName, String? notes, + String? customEmoji, }) { return SarMarker( id: id ?? this.id, @@ -131,6 +173,7 @@ class SarMarker { senderPublicKey: senderPublicKey ?? this.senderPublicKey, senderName: senderName ?? this.senderName, notes: notes ?? this.notes, + customEmoji: customEmoji ?? this.customEmoji, ); } diff --git a/lib/models/sar_template.dart b/lib/models/sar_template.dart new file mode 100644 index 0000000..c9ebb9f --- /dev/null +++ b/lib/models/sar_template.dart @@ -0,0 +1,196 @@ +import 'package:flutter/material.dart'; + +/// SAR Template - Customizable template for SAR (Cursor on Target) messages +class SarTemplate { + final String id; + final String emoji; + final String name; + final String description; + final String colorHex; + final bool isDefault; + + SarTemplate({ + required this.id, + required this.emoji, + required this.name, + required this.description, + required this.colorHex, + this.isDefault = false, + }); + + /// Get color from hex string + Color get color { + final hexCode = colorHex.replaceAll('#', ''); + return Color(int.parse('FF$hexCode', radix: 16)); + } + + /// Create from JSON + factory SarTemplate.fromJson(Map json) { + return SarTemplate( + id: json['id'] as String, + emoji: json['emoji'] as String, + name: json['name'] as String, + description: json['description'] as String? ?? '', + colorHex: json['colorHex'] as String, + isDefault: json['isDefault'] as bool? ?? false, + ); + } + + /// Convert to JSON + Map toJson() { + return { + 'id': id, + 'emoji': emoji, + 'name': name, + 'description': description, + 'colorHex': colorHex, + 'isDefault': isDefault, + }; + } + + /// Create from SAR message format (S:emoji:0,0:description) + /// Example: S:🧑:0,0:Person found + factory SarTemplate.fromSarMessage(String message) { + final trimmed = message.trim(); + if (!trimmed.startsWith('S:')) { + throw FormatException('SAR message must start with "S:"'); + } + + // Parse format: S:emoji:lat,lon:description + final parts = trimmed.split(':'); + if (parts.length < 3) { + throw FormatException('Invalid SAR message format'); + } + + final emoji = parts[1].trim(); + if (emoji.isEmpty) { + throw FormatException('Emoji cannot be empty'); + } + + // Extract description (everything after the third colon) + String description = ''; + if (parts.length > 3) { + description = parts.sublist(3).join(':').trim(); + } + + // Generate ID from emoji + description + final id = '${emoji}_${DateTime.now().millisecondsSinceEpoch}'; + + // Auto-assign color based on emoji + String colorHex = _getColorForEmoji(emoji); + + return SarTemplate( + id: id, + emoji: emoji, + name: description.isNotEmpty ? description : emoji, + description: description, + colorHex: colorHex, + isDefault: false, + ); + } + + /// Convert to SAR message format with placeholder coordinates + /// Example: S:🧑:0,0:Person found + String toSarMessage() { + if (description.isNotEmpty) { + return 'S:$emoji:0,0:$description'; + } + return 'S:$emoji:0,0'; + } + + /// Auto-assign color based on emoji + static String _getColorForEmoji(String emoji) { + // Default emoji to color mapping + final colorMap = { + '🧑': '#4CAF50', // Green - Person + '👤': '#4CAF50', // Green - Person + '🔥': '#F44336', // Red - Fire + '🏕️': '#FF9800', // Orange - Staging + '⛺': '#FF9800', // Orange - Staging + '📦': '#9C27B0', // Purple - Object + '🚁': '#2196F3', // Blue - Helicopter + '🚒': '#F44336', // Red - Fire truck + '🚑': '#F44336', // Red - Ambulance + '⚠️': '#FFC107', // Yellow - Warning + '❌': '#F44336', // Red - Hazard + '✅': '#4CAF50', // Green - Safe + '🏥': '#F44336', // Red - Medical + '💧': '#2196F3', // Blue - Water + '🌲': '#4CAF50', // Green - Forest + '⛰️': '#795548', // Brown - Mountain + }; + + return colorMap[emoji] ?? '#9E9E9E'; // Default gray + } + + /// Copy with modifications + SarTemplate copyWith({ + String? id, + String? emoji, + String? name, + String? description, + String? colorHex, + bool? isDefault, + }) { + return SarTemplate( + id: id ?? this.id, + emoji: emoji ?? this.emoji, + name: name ?? this.name, + description: description ?? this.description, + colorHex: colorHex ?? this.colorHex, + isDefault: isDefault ?? this.isDefault, + ); + } + + @override + String toString() { + return 'SarTemplate(id: $id, emoji: $emoji, name: $name, description: $description)'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is SarTemplate && id == other.id; + } + + @override + int get hashCode => id.hashCode; + + /// Default templates (matches existing SarMarkerType) + static List get defaults { + return [ + SarTemplate( + id: 'default_found_person', + emoji: '🧑', + name: 'Found Person', + description: '', + colorHex: '#4CAF50', + isDefault: true, + ), + SarTemplate( + id: 'default_fire', + emoji: '🔥', + name: 'Fire', + description: '', + colorHex: '#F44336', + isDefault: true, + ), + SarTemplate( + id: 'default_staging_area', + emoji: '🏕️', + name: 'Staging Area', + description: '', + colorHex: '#FF9800', + isDefault: true, + ), + SarTemplate( + id: 'default_object', + emoji: '📦', + name: 'Object', + description: '', + colorHex: '#9C27B0', + isDefault: true, + ), + ]; + } +} diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index a78dcf4..dd52e9a 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -24,6 +24,9 @@ class AppProvider with ChangeNotifier { bool _isInitialized = false; bool get isInitialized => _isInitialized; + bool _isSimpleMode = false; + bool get isSimpleMode => _isSimpleMode; + AppProvider({ required this.connectionProvider, required this.contactsProvider, @@ -35,9 +38,33 @@ class AppProvider with ChangeNotifier { _setupCallbacks(); _initializeTileCache(); _initializeLocationTracking(); + _loadSimpleMode(); _isInitialized = true; } + /// Load simple mode setting from shared preferences + Future _loadSimpleMode() async { + try { + final prefs = await SharedPreferences.getInstance(); + _isSimpleMode = prefs.getBool('simple_mode') ?? false; + notifyListeners(); + } catch (e) { + debugPrint('Error loading simple mode setting: $e'); + } + } + + /// Toggle simple mode on/off + Future toggleSimpleMode(bool enabled) async { + try { + _isSimpleMode = enabled; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('simple_mode', enabled); + notifyListeners(); + } catch (e) { + debugPrint('Error saving simple mode setting: $e'); + } + } + /// Initialize tile cache service Future _initializeTileCache() async { try { @@ -116,19 +143,17 @@ class AppProvider with ChangeNotifier { final drawing = DrawingMessageParser.parseDrawingMessage( message.text, senderName: senderName, + messageId: message.id, // Pass message ID for navigation linking ); if (drawing != null) { debugPrint('🎨 [AppProvider] Drawing parsed successfully: ${drawing.type.name} from ${drawing.senderName ?? "unknown"}'); + debugPrint(' Drawing linked to message ID: ${message.id}'); drawingProvider.addReceivedDrawing(drawing); - // Add informational message to chat - final drawingTypeStr = drawing.type.name.substring(0, 1).toUpperCase() + - drawing.type.name.substring(1); - final infoMessage = message.copyWith( - text: '📍 Received map drawing ($drawingTypeStr) from ${drawing.senderName ?? "unknown"}', - ); + // Add the original drawing message to chat (not a modified info message) + // This allows users to click on the drawing message to navigate to it messagesProvider.addMessage( - infoMessage, + message, contactLookup: (name) => '', ); } else { @@ -238,12 +263,11 @@ class AppProvider with ChangeNotifier { try { // Initialize contacts provider with device public key to exclude self + // If already initialized (from early load), this will just filter out self-contact // This must happen before getContacts to ensure proper filtering - if (!contactsProvider.isInitialized) { - await contactsProvider.initialize( - devicePublicKey: connectionProvider.deviceInfo.publicKey, - ); - } + await contactsProvider.initialize( + devicePublicKey: connectionProvider.deviceInfo.publicKey, + ); // Note: Device clock is automatically synced during connection in MeshCoreBleService // No need to sync it again here @@ -257,9 +281,12 @@ class AppProvider with ChangeNotifier { // Small delay to ensure contacts are fully loaded await Future.delayed(const Duration(milliseconds: 500)); - // Sync all channels to get channel names - debugPrint('📻 [AppProvider] Syncing channels...'); - await connectionProvider.syncChannels(); + // Sync channels to get channel names + // In simple mode: only sync first 5 channels for faster startup + // In normal mode: sync all channels (up to device max) + final channelsToSync = _isSimpleMode ? 5 : null; + debugPrint('📻 [AppProvider] Syncing channels${_isSimpleMode ? ' (simple mode: max 5)' : ''}...'); + await connectionProvider.syncChannels(maxChannels: channelsToSync); debugPrint('✅ [AppProvider] Channel sync complete'); // Configure the default public channel (channel 0) diff --git a/lib/providers/contacts_provider.dart b/lib/providers/contacts_provider.dart index 13a7a0e..6a5f2d5 100644 --- a/lib/providers/contacts_provider.dart +++ b/lib/providers/contacts_provider.dart @@ -17,10 +17,49 @@ class ContactsProvider with ChangeNotifier { bool get isInitialized => _isInitialized; + /// Initialize and load persisted contacts at app startup + /// This loads contacts without filtering, allowing offline viewing + Future initializeEarly() async { + if (_isInitialized) return; + + try { + debugPrint('📦 [ContactsProvider] Early loading persisted contacts (no filtering)...'); + final storedContacts = await _storageService.loadContacts(); + + // Add stored contacts (excluding any with all-zeros public key) + const publicChannelKey = '0000000000000000000000000000000000000000000000000000000000000000'; + for (final contact in storedContacts) { + // Skip any contacts with all-zeros public key (shouldn't happen, but safety check) + if (contact.publicKeyHex == publicChannelKey) { + continue; + } + _contacts[contact.publicKeyHex] = contact; + } + + _isInitialized = true; + debugPrint('✅ [ContactsProvider] Early loaded ${storedContacts.length} persisted contacts'); + + // Ensure public channel exists after loading + _ensurePublicChannelExists(); + + notifyListeners(); + } catch (e) { + debugPrint('❌ [ContactsProvider] Error in early initialization: $e'); + _isInitialized = true; // Mark as initialized even on error + _ensurePublicChannelExists(); + } + } + /// Initialize and load persisted contacts /// [devicePublicKey] - device's own public key to exclude from loaded contacts Future initialize({Uint8List? devicePublicKey}) async { - if (_isInitialized) return; + if (_isInitialized) { + // If already initialized (from early load), just filter out self-contact + if (devicePublicKey != null) { + _removeSelfContact(devicePublicKey); + } + return; + } try { debugPrint('📦 [ContactsProvider] Loading persisted contacts...'); @@ -52,6 +91,18 @@ class ContactsProvider with ChangeNotifier { } } + /// Remove self-contact from loaded contacts (called after BLE connection established) + void _removeSelfContact(Uint8List devicePublicKey) { + final selfKeyHex = devicePublicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join(''); + if (_contacts.containsKey(selfKeyHex)) { + final selfContact = _contacts[selfKeyHex]!; + debugPrint('🗑️ [ContactsProvider] Removing self-contact: ${selfContact.advName}'); + _contacts.remove(selfKeyHex); + _persistContacts(); + notifyListeners(); + } + } + /// Ensure public channel always exists in the list void _ensurePublicChannelExists() { // Public channel has all-zeros public key (32 bytes = 64 hex chars) diff --git a/lib/providers/drawing_provider.dart b/lib/providers/drawing_provider.dart index d3ebd55..69a453b 100644 --- a/lib/providers/drawing_provider.dart +++ b/lib/providers/drawing_provider.dart @@ -20,6 +20,8 @@ class DrawingProvider with ChangeNotifier { // Drawing state DrawingMode _drawingMode = DrawingMode.none; Color _selectedColor = DrawingColors.palette[0]; + bool _showReceivedDrawings = true; + bool _showSarMarkers = true; // Completed drawings final List _drawings = []; @@ -32,7 +34,11 @@ class DrawingProvider with ChangeNotifier { // Getters DrawingMode get drawingMode => _drawingMode; Color get selectedColor => _selectedColor; - List get drawings => List.unmodifiable(_drawings); + bool get showReceivedDrawings => _showReceivedDrawings; + bool get showSarMarkers => _showSarMarkers; + List get drawings => _showReceivedDrawings + ? List.unmodifiable(_drawings) + : List.unmodifiable(_drawings.where((d) => !d.isReceived).toList()); MapDrawing? get currentDrawing => _currentDrawing; List get currentLinePoints => List.unmodifiable(_currentLinePoints); LatLng? get rectangleStartPoint => _rectangleStartPoint; @@ -59,6 +65,18 @@ class DrawingProvider with ChangeNotifier { notifyListeners(); } + /// Toggle visibility of received drawings + void toggleReceivedDrawings() { + _showReceivedDrawings = !_showReceivedDrawings; + notifyListeners(); + } + + /// Toggle visibility of SAR markers + void toggleSarMarkers() { + _showSarMarkers = !_showSarMarkers; + notifyListeners(); + } + /// Start drawing a line void startLine(LatLng point) { if (_drawingMode != DrawingMode.line) return; diff --git a/lib/providers/messages_provider.dart b/lib/providers/messages_provider.dart index cf6c21f..0297b3a 100644 --- a/lib/providers/messages_provider.dart +++ b/lib/providers/messages_provider.dart @@ -31,6 +31,9 @@ class MessagesProvider with ChangeNotifier { // Track which contact each sent message was sent to (for retry logic) final Map _messageContactMap = {}; + // Navigation state for message highlighting/scrolling + String? _targetMessageId; + // Callback to connection provider for sending messages (set by AppProvider) Future Function({ required Uint8List contactPublicKey, @@ -70,11 +73,24 @@ class MessagesProvider with ChangeNotifier { bool get isInitialized => _isInitialized; + String? get targetMessageId => _targetMessageId; + /// Set localizations for notifications void setLocalizations(AppLocalizations localizations) { _localizations = localizations; } + /// Navigate to a specific message (scroll and highlight) + void navigateToMessage(String messageId) { + _targetMessageId = messageId; + notifyListeners(); + } + + /// Clear message navigation state + void clearMessageNavigation() { + _targetMessageId = null; + } + /// Get count of unread messages (excluding sent messages and system messages) int get unreadCount => _messages .where((m) => @@ -178,6 +194,9 @@ class MessagesProvider with ChangeNotifier { _triggerSarNotification(finalMessage, marker); } } + } else if (!finalMessage.isSentMessage && !finalMessage.isSystemMessage) { + // Trigger notification for regular messages (not SAR, not sent by user, not system) + _triggerMessageNotification(finalMessage); } // Persist to storage asynchronously @@ -296,6 +315,40 @@ class MessagesProvider with ChangeNotifier { } } + /// Trigger notification for regular message + Future _triggerMessageNotification(Message message) async { + try { + // Get sender name from message + final senderName = message.senderName ?? message.senderKeyShort ?? 'Unknown'; + + // Determine if it's a channel message + final isChannelMessage = message.isChannelMessage; + + // Get channel name if available + String? channelName; + if (isChannelMessage) { + // You could map channelIdx to channel name here if needed + // For now, use "Public" for channel 0 + channelName = message.channelIdx == 0 ? 'Public' : 'Channel ${message.channelIdx}'; + } + + debugPrint('🔔 [MessagesProvider] Triggering message notification'); + debugPrint(' Sender: $senderName'); + debugPrint(' Type: ${isChannelMessage ? "Channel" : "Direct"}'); + debugPrint(' Message: ${message.text.substring(0, message.text.length > 50 ? 50 : message.text.length)}...'); + + await _notificationService.showMessageNotification( + senderName: senderName, + messageText: message.text, + isChannelMessage: isChannelMessage, + channelName: channelName, + localizations: _localizations, + ); + } catch (e) { + debugPrint('❌ [MessagesProvider] Error triggering message notification: $e'); + } + } + /// Persist messages to storage (async, non-blocking) Future _persistMessages() async { try { @@ -529,6 +582,11 @@ class MessagesProvider with ChangeNotifier { if (sendingMessage.isSarMarker) { final marker = sendingMessage.toSarMarker(); if (marker != null) { + debugPrint(' ✅ SAR Marker created:'); + debugPrint(' marker.id: ${marker.id}'); + debugPrint(' marker.notes: "${marker.notes}"'); + debugPrint(' marker.type: ${marker.type}'); + debugPrint(' marker.displayName: ${marker.displayName}'); _sarMarkers[marker.id] = marker; } } diff --git a/lib/screens/contacts_tab.dart b/lib/screens/contacts_tab.dart index c211015..1014cf6 100644 --- a/lib/screens/contacts_tab.dart +++ b/lib/screens/contacts_tab.dart @@ -8,7 +8,9 @@ import '../providers/app_provider.dart'; import '../widgets/contacts/contact_tile.dart'; class ContactsTab extends StatefulWidget { - const ContactsTab({super.key}); + final VoidCallback? onNavigateToMap; + + const ContactsTab({super.key, this.onNavigateToMap}); @override State createState() => _ContactsTabState(); @@ -87,6 +89,9 @@ class _ContactsTabState extends State { @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; + final appProvider = context.watch(); + final isSimpleMode = appProvider.isSimpleMode; + return Consumer( builder: (context, contactsProvider, child) { final chatContacts = contactsProvider.chatContacts; @@ -143,6 +148,7 @@ class _ContactsTabState extends State { currentPosition: _currentPosition, calculateDistance: _calculateDistanceInMeters, formatDistance: _formatDistance, + onNavigateToMap: widget.onNavigateToMap, ), ), const Divider(height: 32), @@ -161,6 +167,7 @@ class _ContactsTabState extends State { currentPosition: _currentPosition, calculateDistance: _calculateDistanceInMeters, formatDistance: _formatDistance, + onNavigateToMap: widget.onNavigateToMap, ), ), const Divider(height: 32), @@ -179,13 +186,14 @@ class _ContactsTabState extends State { currentPosition: _currentPosition, calculateDistance: _calculateDistanceInMeters, formatDistance: _formatDistance, + onNavigateToMap: widget.onNavigateToMap, ), ), const Divider(height: 32), ], - // Channels - if (channels.isNotEmpty) ...[ + // Channels (hidden in simple mode) + if (!isSimpleMode && channels.isNotEmpty) ...[ _SectionHeader( title: l10n.channels, count: channels.length, @@ -197,6 +205,7 @@ class _ContactsTabState extends State { currentPosition: _currentPosition, calculateDistance: _calculateDistanceInMeters, formatDistance: _formatDistance, + onNavigateToMap: widget.onNavigateToMap, ), ), ], diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 7db40b2..04215c6 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -581,13 +581,14 @@ class _HomeScreenState extends State controller: _tabController, children: [ MessagesTab(onNavigateToMap: () => _tabController.animateTo(2)), - const ContactsTab(), + ContactsTab(onNavigateToMap: () => _tabController.animateTo(2)), MapTab( onFullscreenChanged: (isFullscreen) { setState(() { _isMapFullscreen = isFullscreen; }); }, + onNavigateToMessages: () => _tabController.animateTo(0), ), ], ), @@ -772,32 +773,35 @@ class _HomeScreenState extends State ], ), ), - const SizedBox(width: 8), - GestureDetector( - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const DeviceConfigScreen(), - ), - ); - }, - onLongPress: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => - PacketLogScreen(bleService: provider.bleService), - ), - ); - }, - child: Container( - width: 32, - height: 32, - alignment: Alignment.center, - child: const Icon(Icons.settings, size: 18), + // Settings cog - hidden in simple mode + if (!context.watch().isSimpleMode) ...[ + const SizedBox(width: 8), + GestureDetector( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const DeviceConfigScreen(), + ), + ); + }, + onLongPress: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + PacketLogScreen(bleService: provider.bleService), + ), + ); + }, + child: Container( + width: 32, + height: 32, + alignment: Alignment.center, + child: const Icon(Icons.settings, size: 18), + ), ), - ), + ], ], ), ), diff --git a/lib/screens/map_management_screen.dart b/lib/screens/map_management_screen.dart index c22fed7..ae49e44 100644 --- a/lib/screens/map_management_screen.dart +++ b/lib/screens/map_management_screen.dart @@ -3,6 +3,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; import 'package:file_picker/file_picker.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart'; import '../services/tile_cache_service.dart'; import '../services/validation_service.dart'; import '../services/mbtiles_service.dart'; @@ -391,6 +393,133 @@ class _MapManagementScreenState extends State { } } + Future _exportTiles() async { + try { + // Check if there are tiles to export + final tileCount = await widget.tileCacheService.getCachedTileCount(); + if (tileCount == 0) { + _showError(AppLocalizations.of(context)!.noTilesToExport); + return; + } + + if (!mounted) return; + setState(() { + _isLoading = true; + _statusMessage = AppLocalizations.of(context)!.exportingTiles; + }); + + // Export to temporary directory first (works on all platforms) + final tempDir = await getTemporaryDirectory(); + final fileName = 'meshcore_tiles_${DateTime.now().millisecondsSinceEpoch}.fmtc'; + final tempFilePath = '${tempDir.path}/$fileName'; + + final exportedCount = await widget.tileCacheService.exportStore(tempFilePath); + + if (!mounted) return; + setState(() { + _isLoading = false; + _statusMessage = null; + }); + + // Share the file using share_plus (works on all platforms) + final file = File(tempFilePath); + if (await file.exists()) { + // Get the button position for iPad popover + final box = context.findRenderObject() as RenderBox?; + final sharePositionOrigin = box != null + ? box.localToGlobal(Offset.zero) & box.size + : null; + + final result = await SharePlus.instance.share( + ShareParams( + files: [XFile(tempFilePath)], + subject: 'MeshCore Map Tiles Export', + text: 'Exported $exportedCount map tiles', + sharePositionOrigin: sharePositionOrigin, + ), + ); + + if (mounted) { + if (result.status == ShareResultStatus.success) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.exportSuccess(exportedCount)), + backgroundColor: Colors.green, + ), + ); + } + } + } else { + _showError('Export file not found'); + } + } catch (e) { + if (!mounted) return; + setState(() { + _isLoading = false; + _statusMessage = null; + }); + _showError(AppLocalizations.of(context)!.exportFailed(e.toString())); + } + } + + Future _importTiles() async { + try { + // Use file picker to select import file + final result = await FilePicker.platform.pickFiles( + dialogTitle: AppLocalizations.of(context)!.selectImportFile, + type: FileType.custom, + allowedExtensions: ['fmtc'], + ); + + if (result == null || result.files.isEmpty) return; + + final filePath = result.files.first.path; + if (filePath == null) return; + + if (!mounted) return; + setState(() { + _isLoading = true; + _statusMessage = AppLocalizations.of(context)!.importingTiles; + }); + + // Optional: Preview stores in archive before importing + try { + final stores = await widget.tileCacheService.listArchiveStores(filePath); + debugPrint('Archive contains stores: $stores'); + } catch (e) { + debugPrint('Could not list stores: $e'); + } + + final importResult = await widget.tileCacheService.importStore(filePath); + + if (!mounted) return; + setState(() { + _isLoading = false; + _statusMessage = null; + }); + + await _loadCacheStats(); // Refresh stats after import + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context)!.importSuccess(importResult['successfulStores'] as int), + ), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (!mounted) return; + setState(() { + _isLoading = false; + _statusMessage = null; + }); + _showError(AppLocalizations.of(context)!.importFailed(e.toString())); + } + } + void _showError(String message) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -424,6 +553,10 @@ class _MapManagementScreenState extends State { _buildMbtilesCard(), const SizedBox(height: 16), + // Import/Export Cached Tiles + _buildImportExportCard(), + const SizedBox(height: 16), + // Download Region _buildDownloadCard(), const SizedBox(height: 16), @@ -636,6 +769,60 @@ class _MapManagementScreenState extends State { ); } + Widget _buildImportExportCard() { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + AppLocalizations.of(context)!.importExportCachedTiles, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text( + AppLocalizations.of(context)!.importExportDescription, + style: TextStyle(fontSize: 12, color: Colors.grey[600]), + ), + const SizedBox(height: 16), + + // Export Section + ElevatedButton.icon( + onPressed: _isDownloading || _isLoading ? null : _exportTiles, + icon: const Icon(Icons.file_upload), + label: Text(AppLocalizations.of(context)!.exportTilesToFile), + style: ElevatedButton.styleFrom( + minimumSize: const Size.fromHeight(48), + ), + ), + const SizedBox(height: 8), + Text( + AppLocalizations.of(context)!.exportNote, + style: TextStyle(fontSize: 12, color: Colors.grey[600]), + ), + const SizedBox(height: 16), + + // Import Section + ElevatedButton.icon( + onPressed: _isDownloading || _isLoading ? null : _importTiles, + icon: const Icon(Icons.file_download), + label: Text(AppLocalizations.of(context)!.importTilesFromFile), + style: ElevatedButton.styleFrom( + minimumSize: const Size.fromHeight(48), + ), + ), + const SizedBox(height: 8), + Text( + AppLocalizations.of(context)!.importNote, + style: TextStyle(fontSize: 12, color: Colors.grey[600]), + ), + ], + ), + ), + ); + } + Widget _buildInfoRow(String label, String value) { return Padding( padding: const EdgeInsets.symmetric(vertical: 4), diff --git a/lib/screens/map_tab.dart b/lib/screens/map_tab.dart index 52b0157..0bfb962 100644 --- a/lib/screens/map_tab.dart +++ b/lib/screens/map_tab.dart @@ -41,10 +41,12 @@ import 'map_management_screen.dart'; class MapTab extends StatefulWidget { final Function(bool)? onFullscreenChanged; + final VoidCallback? onNavigateToMessages; const MapTab({ super.key, this.onFullscreenChanged, + this.onNavigateToMessages, }); @override @@ -96,6 +98,15 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { // Access the singleton LocationTrackingService from AppProvider LocationTrackingService get _locationService => LocationTrackingService(); + /// Helper to compare public keys byte-by-byte + bool _publicKeysMatch(Uint8List key1, Uint8List key2) { + if (key1.length != key2.length) return false; + for (int i = 0; i < key1.length; i++) { + if (key1[i] != key2[i]) return false; + } + return true; + } + @override void initState() { super.initState(); @@ -867,17 +878,17 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { builder: (context) => SarUpdateSheet( prePopulatedPosition: position, allowLocationUpdate: false, // Don't allow changing to current location - onSend: (sarType, position, notes, roomPublicKey, sendToChannel) async { - await _sendSarMessage(sarType, position, notes, roomPublicKey, sendToChannel); + onSend: (emoji, name, position, roomPublicKey, sendToChannel) async { + await _sendSarMessage(emoji, name, position, roomPublicKey, sendToChannel); }, ), ); } Future _sendSarMessage( - SarMarkerType sarType, + String emoji, + String name, Position position, - String? notes, Uint8List? roomPublicKey, bool sendToChannel, ) async { @@ -907,27 +918,50 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { } try { - // Format: S::, - final sarMessage = 'S:${sarType.emoji}:${position.latitude},${position.longitude}'; - - // Add notes if provided - final fullMessage = notes != null && notes.isNotEmpty - ? '$sarMessage $notes' - : sarMessage; + // Format: S::,: + // Round coordinates to 5 decimal places (~1m accuracy) since most GPS is only that accurate + final sarMessage = 'S:$emoji:${position.latitude.toStringAsFixed(5)},${position.longitude.toStringAsFixed(5)}:$name'; if (sendToChannel) { + // Create message ID + final messageId = '${DateTime.now().millisecondsSinceEpoch}_channel_sent'; + final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; + + // Get current device's public key (first 6 bytes) + final devicePublicKey = connectionProvider.deviceInfo.publicKey; + final senderPublicKeyPrefix = devicePublicKey?.sublist(0, 6); + + // Create sent message object + final sentMessage = Message( + id: messageId, + messageType: MessageType.channel, + senderPublicKeyPrefix: senderPublicKeyPrefix, + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: timestamp, + text: sarMessage, + receivedAt: DateTime.now(), + deliveryStatus: MessageDeliveryStatus.sending, + channelIdx: 0, + // SAR marker data is automatically added by SarMessageParser.enhanceMessage in MessagesProvider + ); + + // Add to messages list with "sending" status + messagesProvider.addSentMessage(sentMessage); + // Send to public channel (ephemeral, over-the-air only) await connectionProvider.sendChannelMessage( channelIdx: 0, - text: fullMessage, + text: sarMessage, + messageId: messageId, ); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('${sarType.displayName} marker broadcast to public channel'), + const SnackBar( + content: Text('SAR marker broadcast to public channel'), backgroundColor: Colors.orange, - duration: const Duration(seconds: 2), + duration: Duration(seconds: 2), ), ); } else { @@ -939,7 +973,7 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { final devicePublicKey = connectionProvider.deviceInfo.publicKey; final senderPublicKeyPrefix = devicePublicKey?.sublist(0, 6); - // Create sent message object + // Create sent message object with recipient public key for retry support final sentMessage = Message( id: messageId, messageType: MessageType.contact, @@ -947,20 +981,29 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { pathLen: 0, textType: MessageTextType.plain, senderTimestamp: timestamp, - text: fullMessage, + text: sarMessage, receivedAt: DateTime.now(), deliveryStatus: MessageDeliveryStatus.sending, + recipientPublicKey: roomPublicKey, // Store recipient for retry // SAR marker data is automatically added by SarMessageParser.enhanceMessage in MessagesProvider ); // Add to messages list with "sending" status messagesProvider.addSentMessage(sentMessage); + // Look up the room contact for path logging + final contactsProvider = context.read(); + final roomContact = contactsProvider.contacts.where((c) { + return c.publicKey.length >= roomPublicKey!.length && + _publicKeysMatch(c.publicKey, roomPublicKey); + }).firstOrNull; + // Send SAR message to selected room (persisted and immutable) final sentSuccessfully = await connectionProvider.sendTextMessage( contactPublicKey: roomPublicKey!, - text: fullMessage, + text: sarMessage, messageId: messageId, // Pass message ID so it can be tracked + contact: roomContact, // Include contact for path status logging ); if (!sentSuccessfully) { @@ -970,10 +1013,10 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('${sarType.displayName} marker sent to room'), + const SnackBar( + content: Text('SAR marker sent to room'), backgroundColor: Colors.green, - duration: const Duration(seconds: 2), + duration: Duration(seconds: 2), ), ); } @@ -991,10 +1034,17 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { @override Widget build(BuildContext context) { super.build(context); // Required for AutomaticKeepAliveClientMixin + final appProvider = context.watch(); + final isSimpleMode = appProvider.isSimpleMode; + return Consumer3( builder: (context, contactsProvider, messagesProvider, drawingProvider, child) { final contactsWithLocation = contactsProvider.contactsWithLocation; - final sarMarkers = messagesProvider.sarMarkers; + // Filter SAR markers based on visibility toggle + final allSarMarkers = messagesProvider.sarMarkers; + final sarMarkers = drawingProvider.showSarMarkers + ? allSarMarkers + : []; final center = _calculateCenter(contactsWithLocation, sarMarkers); return Stack( @@ -1167,6 +1217,7 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { DrawingLayer( drawings: drawingProvider.drawings, previewDrawing: drawingProvider.getPreviewDrawing(), + isSimpleMode: isSimpleMode, ), MarkerLayer( markers: [ @@ -1191,12 +1242,9 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { context: context, mapRotation: _getMapRotation(), onTap: (marker) { - _showDetailedCompassWithSarMarker( - context, - contactsProvider.contactsWithLocation, - messagesProvider.sarMarkers, - marker, - ); + // Navigate to the corresponding message in Messages tab + messagesProvider.navigateToMessage(marker.id); + widget.onNavigateToMessages?.call(); }, ), // User location marker @@ -1284,9 +1332,17 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { DrawingMarkersLayer( drawings: drawingProvider.drawings, showDeleteButtons: drawingProvider.isDrawing, + isSimpleMode: isSimpleMode, onDeleteDrawing: (drawingId) { drawingProvider.removeDrawing(drawingId); }, + onTapDrawing: (drawing) { + // Navigate to the corresponding message in Messages tab + if (drawing.messageId != null) { + messagesProvider.navigateToMessage(drawing.messageId!); + widget.onNavigateToMessages?.call(); + } + }, ), ], ), @@ -1406,6 +1462,21 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { child: const Icon(Icons.layers), ), const SizedBox(height: 8), + // In simple mode: show fullscreen button directly + // In normal mode: show options menu (which includes fullscreen) + if (context.watch().isSimpleMode) + FloatingActionButton.small( + heroTag: 'fullscreen_toggle', + onPressed: () { + setState(() { + _isFullscreen = !_isFullscreen; + }); + _saveSettings(); + widget.onFullscreenChanged?.call(_isFullscreen); + }, + child: Icon(_isFullscreen ? Icons.fullscreen_exit : Icons.fullscreen), + ) + else FloatingActionButton.small( heroTag: 'options_menu', onPressed: () => _showOptionsMenu(context), diff --git a/lib/screens/messages_tab.dart b/lib/screens/messages_tab.dart index a349f92..7240781 100644 --- a/lib/screens/messages_tab.dart +++ b/lib/screens/messages_tab.dart @@ -1,4 +1,3 @@ -import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; @@ -8,12 +7,14 @@ import '../providers/contacts_provider.dart'; import '../providers/map_provider.dart'; import '../providers/connection_provider.dart'; import '../models/message.dart'; +import '../models/contact.dart'; import '../models/sar_marker.dart'; import '../widgets/messages/sar_update_sheet.dart'; +import '../widgets/messages/recipient_selector_sheet.dart'; import '../widgets/contacts/direct_message_sheet.dart'; +import '../services/message_destination_preferences.dart'; import '../utils/toast_logger.dart'; import '../l10n/app_localizations.dart'; -import '../utils/sar_marker_extensions.dart'; import '../utils/message_extensions.dart'; class MessagesTab extends StatefulWidget { @@ -28,8 +29,14 @@ class MessagesTab extends StatefulWidget { class _MessagesTabState extends State { final TextEditingController _textController = TextEditingController(); final FocusNode _focusNode = FocusNode(); + final ScrollController _scrollController = ScrollController(); int _characterCount = 0; static const int _maxCharacters = 160; + String? _highlightedMessageId; + + // Message destination state + String _destinationType = MessageDestinationPreferences.destinationTypeChannel; + Contact? _selectedRecipient; /// Helper method to compare two public keys for equality bool _publicKeysMatch(Uint8List key1, Uint8List key2) { @@ -44,9 +51,21 @@ class _MessagesTabState extends State { void initState() { super.initState(); _textController.addListener(_updateCharacterCount); + // Load saved message destination + _loadSavedDestination(); // Mark all messages as read when tab is opened WidgetsBinding.instance.addPostFrameCallback((_) { context.read().markAllAsRead(); + _checkForNavigationRequest(); + }); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // Check for navigation request whenever dependencies change + WidgetsBinding.instance.addPostFrameCallback((_) { + _checkForNavigationRequest(); }); } @@ -54,21 +73,183 @@ class _MessagesTabState extends State { void dispose() { _textController.dispose(); _focusNode.dispose(); + _scrollController.dispose(); super.dispose(); } + void _checkForNavigationRequest() { + final messagesProvider = context.read(); + final targetMessageId = messagesProvider.targetMessageId; + + if (targetMessageId != null) { + _scrollToMessage(targetMessageId); + messagesProvider.clearMessageNavigation(); + } + } + + void _scrollToMessage(String messageId) { + final messagesProvider = context.read(); + final messages = _getFilteredMessages(messagesProvider); + + final messageIndex = messages.indexWhere((m) => m.id == messageId); + + if (messageIndex != -1 && _scrollController.hasClients) { + // Calculate position - accounting for reverse list + final itemHeight = 80.0; // Approximate height of a message bubble + final targetOffset = messageIndex * itemHeight; + + // Scroll to the message + _scrollController.animateTo( + targetOffset, + duration: const Duration(milliseconds: 500), + curve: Curves.easeInOut, + ); + + // Highlight the message briefly + setState(() { + _highlightedMessageId = messageId; + }); + + // Clear highlight after 2 seconds + Future.delayed(const Duration(seconds: 2), () { + if (mounted) { + setState(() { + _highlightedMessageId = null; + }); + } + }); + } + } + void _updateCharacterCount() { setState(() { _characterCount = _textController.text.length; }); } + /// Load saved message destination from preferences + Future _loadSavedDestination() async { + final savedDestination = await MessageDestinationPreferences.getDestination(); + + if (savedDestination == null || !mounted) { + // Default to public channel + return; + } + + final type = savedDestination['type']!; + final publicKey = savedDestination['publicKey']; + + setState(() { + _destinationType = type; + }); + + // If it's a contact or room, try to find it in the contacts list + if (publicKey != null && mounted) { + final contactsProvider = context.read(); + final contact = contactsProvider.contacts.where((c) { + return c.publicKeyHex == publicKey; + }).firstOrNull; + + if (contact != null) { + setState(() { + _selectedRecipient = contact; + }); + } else { + // Contact/room not found, fallback to public channel + debugPrint( + '⚠️ [MessagesTab] Saved recipient not found, falling back to public channel', + ); + setState(() { + _destinationType = MessageDestinationPreferences.destinationTypeChannel; + _selectedRecipient = null; + }); + await MessageDestinationPreferences.clearDestination(); + } + } + } + + /// Show recipient selector bottom sheet + void _showRecipientSelector() { + final contactsProvider = context.read(); + + // Filter contacts by type + final contacts = contactsProvider.contacts + .where((c) => c.type == ContactType.chat) + .toList(); + final rooms = contactsProvider.contacts + .where((c) => c.type == ContactType.room) + .toList(); + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => RecipientSelectorSheet( + contacts: contacts, + rooms: rooms, + currentDestinationType: _destinationType, + currentRecipientPublicKey: _selectedRecipient?.publicKeyHex, + onSelect: _onRecipientSelected, + ), + ); + } + + /// Handle recipient selection + Future _onRecipientSelected(String type, Contact? recipient) async { + // Get display name before async gap + final recipientName = type == MessageDestinationPreferences.destinationTypeChannel + ? AppLocalizations.of(context)!.publicChannel + : (recipient?.displayName ?? recipient?.advName ?? 'Unknown'); + + setState(() { + _destinationType = type; + _selectedRecipient = recipient; + }); + + // Save to preferences + await MessageDestinationPreferences.setDestination( + type, + recipientPublicKey: recipient?.publicKeyHex, + ); + + // Show confirmation toast + if (!mounted) return; + ToastLogger.success( + context, + 'Messages will be sent to: $recipientName', + ); + } + + /// Get icon for current destination type + IconData _getDestinationIcon() { + if (_destinationType == MessageDestinationPreferences.destinationTypeChannel) { + return Icons.public; + } else if (_destinationType == MessageDestinationPreferences.destinationTypeRoom) { + return Icons.meeting_room; + } else { + return Icons.person; + } + } + + /// Get tooltip for destination button + String _getDestinationTooltip() { + final l10n = AppLocalizations.of(context)!; + if (_destinationType == MessageDestinationPreferences.destinationTypeChannel) { + return '${l10n.publicChannel} (tap to change)'; + } else if (_selectedRecipient != null) { + final recipientName = _selectedRecipient!.displayName ?? _selectedRecipient!.advName; + return '$recipientName (tap to change)'; + } + return 'Select recipient'; + } + Future _sendMessage() async { final text = _textController.text.trim(); if (text.isEmpty) return; final connectionProvider = context.read(); final messagesProvider = context.read(); + final contactsProvider = context.read(); if (!connectionProvider.deviceInfo.isConnected) { if (!mounted) return; @@ -77,37 +258,23 @@ class _MessagesTabState extends State { } try { - // Create message ID - final messageId = '${DateTime.now().millisecondsSinceEpoch}_channel_sent'; - final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; - - // Get current device's public key (first 6 bytes) - final devicePublicKey = connectionProvider.deviceInfo.publicKey; - final senderPublicKeyPrefix = devicePublicKey?.sublist(0, 6); - - // Create sent message object - final sentMessage = Message( - id: messageId, - messageType: MessageType.channel, - senderPublicKeyPrefix: senderPublicKeyPrefix, - pathLen: 0, - textType: MessageTextType.plain, - senderTimestamp: timestamp, - text: text, - receivedAt: DateTime.now(), - deliveryStatus: MessageDeliveryStatus.sending, - channelIdx: 0, - ); - - // Add to messages list with "sending" status - messagesProvider.addSentMessage(sentMessage); - - // Send to public channel (channel 0) - await connectionProvider.sendChannelMessage( - channelIdx: 0, - text: text, - messageId: messageId, - ); + // Check destination type and send accordingly + if (_destinationType == MessageDestinationPreferences.destinationTypeChannel) { + // Send to public channel + await _sendToChannel(text, connectionProvider, messagesProvider); + } else if (_selectedRecipient != null) { + // Send to contact or room + await _sendToRecipient( + text, + connectionProvider, + messagesProvider, + contactsProvider, + ); + } else { + // Fallback to public channel if no recipient selected + debugPrint('⚠️ [MessagesTab] No recipient selected, falling back to channel'); + await _sendToChannel(text, connectionProvider, messagesProvider); + } _textController.clear(); _focusNode.unfocus(); @@ -119,17 +286,104 @@ class _MessagesTabState extends State { } } + /// Send message to public channel + Future _sendToChannel( + String text, + ConnectionProvider connectionProvider, + MessagesProvider messagesProvider, + ) async { + // Create message ID + final messageId = '${DateTime.now().millisecondsSinceEpoch}_channel_sent'; + final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; + + // Get current device's public key (first 6 bytes) + final devicePublicKey = connectionProvider.deviceInfo.publicKey; + final senderPublicKeyPrefix = devicePublicKey?.sublist(0, 6); + + // Create sent message object + final sentMessage = Message( + id: messageId, + messageType: MessageType.channel, + senderPublicKeyPrefix: senderPublicKeyPrefix, + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: timestamp, + text: text, + receivedAt: DateTime.now(), + deliveryStatus: MessageDeliveryStatus.sending, + channelIdx: 0, + ); + + // Add to messages list with "sending" status + messagesProvider.addSentMessage(sentMessage); + + // Send to public channel (channel 0) + await connectionProvider.sendChannelMessage( + channelIdx: 0, + text: text, + messageId: messageId, + ); + } + + /// Send message to contact or room + Future _sendToRecipient( + String text, + ConnectionProvider connectionProvider, + MessagesProvider messagesProvider, + ContactsProvider contactsProvider, + ) async { + if (_selectedRecipient == null) return; + + // Create message ID + final messageId = '${DateTime.now().millisecondsSinceEpoch}_sent'; + final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; + + // Get current device's public key (first 6 bytes) + final devicePublicKey = connectionProvider.deviceInfo.publicKey; + final senderPublicKeyPrefix = devicePublicKey?.sublist(0, 6); + + // Create sent message object with recipient public key for retry support + final sentMessage = Message( + id: messageId, + messageType: MessageType.contact, + senderPublicKeyPrefix: senderPublicKeyPrefix, + pathLen: 0, + textType: MessageTextType.plain, + senderTimestamp: timestamp, + text: text, + receivedAt: DateTime.now(), + deliveryStatus: MessageDeliveryStatus.sending, + recipientPublicKey: _selectedRecipient!.publicKey, + ); + + // Add to messages list with "sending" status + messagesProvider.addSentMessage(sentMessage); + + // Send message to selected recipient + final sentSuccessfully = await connectionProvider.sendTextMessage( + contactPublicKey: _selectedRecipient!.publicKey, + text: text, + messageId: messageId, + contact: _selectedRecipient, + ); + + if (!sentSuccessfully) { + // Mark message as failed if sending failed + messagesProvider.markMessageFailed(messageId); + } + } + void _showSarDialog() { showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, builder: (context) => SarUpdateSheet( - onSend: (sarType, position, notes, roomPublicKey, sendToChannel) async { + onSend: (emoji, name, position, roomPublicKey, sendToChannel) async { await _sendSarMessage( - sarType, + emoji, + name, position, - notes, roomPublicKey, sendToChannel, ); @@ -139,9 +393,9 @@ class _MessagesTabState extends State { } Future _sendSarMessage( - SarMarkerType sarType, + String emoji, + String name, Position position, - String? notes, Uint8List? roomPublicKey, bool sendToChannel, ) async { @@ -161,14 +415,10 @@ class _MessagesTabState extends State { } try { - // Format: S::, + // Format: S::,: + // Round coordinates to 5 decimal places (~1m accuracy) since most GPS is only that accurate final sarMessage = - 'S:${sarType.emoji}:${position.latitude},${position.longitude}'; - - // Add notes if provided - final fullMessage = notes != null && notes.isNotEmpty - ? '$sarMessage $notes' - : sarMessage; + 'S:$emoji:${position.latitude.toStringAsFixed(5)},${position.longitude.toStringAsFixed(5)}:$name'; if (sendToChannel) { // Create message ID @@ -187,7 +437,7 @@ class _MessagesTabState extends State { pathLen: 0, textType: MessageTextType.plain, senderTimestamp: timestamp, - text: fullMessage, + text: sarMessage, receivedAt: DateTime.now(), deliveryStatus: MessageDeliveryStatus.sending, channelIdx: 0, @@ -200,14 +450,14 @@ class _MessagesTabState extends State { // Send to public channel (ephemeral, over-the-air only) await connectionProvider.sendChannelMessage( channelIdx: 0, - text: fullMessage, + text: sarMessage, messageId: messageId, ); if (!mounted) return; ToastLogger.success( context, - '${sarType.getLocalizedName(context)} marker broadcast to public channel', + 'SAR marker broadcast to public channel', ); } else { // Create message ID @@ -226,7 +476,7 @@ class _MessagesTabState extends State { pathLen: 0, textType: MessageTextType.plain, senderTimestamp: timestamp, - text: fullMessage, + text: sarMessage, receivedAt: DateTime.now(), deliveryStatus: MessageDeliveryStatus.sending, recipientPublicKey: roomPublicKey, // Store recipient for retry @@ -246,7 +496,7 @@ class _MessagesTabState extends State { // Send SAR message to selected room (persisted and immutable) final sentSuccessfully = await connectionProvider.sendTextMessage( contactPublicKey: roomPublicKey!, - text: fullMessage, + text: sarMessage, messageId: messageId, // Pass message ID so it can be tracked contact: roomContact, // Include contact for path status logging ); @@ -259,7 +509,7 @@ class _MessagesTabState extends State { if (!mounted) return; ToastLogger.success( context, - '${sarType.getLocalizedName(context)} marker sent to room', + 'SAR marker sent to room', ); } } catch (e) { @@ -359,11 +609,13 @@ class _MessagesTabState extends State { ), ) : ListView.builder( + controller: _scrollController, reverse: true, padding: const EdgeInsets.all(8), itemCount: messages.length, itemBuilder: (context, index) { final message = messages[index]; + final isHighlighted = message.id == _highlightedMessageId; // Display system messages with minimal styling if (message.isSystemMessage) { @@ -372,6 +624,7 @@ class _MessagesTabState extends State { return _MessageBubble( message: message, + isHighlighted: isHighlighted, onTap: message.isSarMarker && message.sarGpsCoordinates != null @@ -420,7 +673,22 @@ class _MessagesTabState extends State { ).colorScheme.onPrimaryContainer, ), ), - const SizedBox(width: 8), + const SizedBox(width: 4), + // Destination switcher button + IconButton( + icon: Icon(_getDestinationIcon()), + tooltip: _getDestinationTooltip(), + onPressed: _showRecipientSelector, + style: IconButton.styleFrom( + backgroundColor: _destinationType == MessageDestinationPreferences.destinationTypeChannel + ? Theme.of(context).colorScheme.surfaceContainerHighest + : Theme.of(context).colorScheme.secondaryContainer, + foregroundColor: _destinationType == MessageDestinationPreferences.destinationTypeChannel + ? Theme.of(context).colorScheme.onSurface + : Theme.of(context).colorScheme.onSecondaryContainer, + ), + ), + const SizedBox(width: 4), // Text field with embedded send button Expanded( child: TextField( @@ -431,7 +699,9 @@ class _MessagesTabState extends State { maxLengthEnforcement: MaxLengthEnforcement.enforced, style: const TextStyle(fontSize: 14), decoration: InputDecoration( - hintText: AppLocalizations.of(context)!.typeYourMessage, + hintText: AppLocalizations.of( + context, + )!.typeYourMessage, hintStyle: const TextStyle(fontSize: 14), border: OutlineInputBorder( borderRadius: BorderRadius.circular(24), @@ -448,7 +718,9 @@ class _MessagesTabState extends State { fontSize: 10, color: _characterCount > _maxCharacters * 0.9 ? Colors.orange - : Theme.of(context).textTheme.bodySmall?.color, + : Theme.of( + context, + ).textTheme.bodySmall?.color, ), suffixIcon: IconButton( icon: Icon( @@ -481,8 +753,13 @@ class _MessagesTabState extends State { class _MessageBubble extends StatelessWidget { final Message message; final VoidCallback? onTap; + final bool isHighlighted; - const _MessageBubble({required this.message, this.onTap}); + const _MessageBubble({ + required this.message, + this.onTap, + this.isHighlighted = false, + }); /// Helper method to compare two public keys for equality bool _publicKeysMatch(Uint8List key1, Uint8List key2) { @@ -604,11 +881,11 @@ class _MessageBubble extends StatelessWidget { // Copy text option ListTile( leading: const Icon(Icons.copy), - title: const Text('Copy text'), + title: Text(AppLocalizations.of(context)!.copyText), onTap: () { Clipboard.setData(ClipboardData(text: message.text)); Navigator.pop(context); - ToastLogger.success(context, 'Text copied to clipboard'); + ToastLogger.success(context, AppLocalizations.of(context)!.textCopiedToClipboard); }, ), // Delete message option @@ -672,8 +949,8 @@ class _MessageBubble extends StatelessWidget { showDialog( context: context, builder: (context) => AlertDialog( - title: const Text('Delete message'), - content: const Text('Are you sure you want to delete this message?'), + title: Text(l10n.deleteMessage), + content: Text(l10n.deleteMessageConfirmation), actions: [ TextButton( onPressed: () => Navigator.pop(context), @@ -684,10 +961,10 @@ class _MessageBubble extends StatelessWidget { final messagesProvider = context.read(); messagesProvider.deleteMessage(message.id); Navigator.pop(context); - ToastLogger.info(context, 'Message deleted'); + ToastLogger.info(context, l10n.messageDeleted); }, style: TextButton.styleFrom(foregroundColor: Colors.red), - child: const Text('Delete'), + child: Text(l10n.delete), ), ], ), @@ -820,39 +1097,55 @@ class _MessageBubble extends StatelessWidget { margin: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( - color: isSarMarker - ? _getSarMarkerColor(context, isDarkMode) - : _getMessageBubbleColor(context, isOwnMessage, isDarkMode), + color: isHighlighted + ? Theme.of(context).colorScheme.primaryContainer + : isSarMarker + ? _getSarMarkerColor(context, isDarkMode) + : _getMessageBubbleColor(context, isOwnMessage, isDarkMode), borderRadius: BorderRadius.circular(12), - border: isSarMarker + border: isHighlighted ? Border.all( - color: _getSarMarkerBorderColor(context, isDarkMode), - width: 2, + color: Theme.of(context).colorScheme.primary, + width: 3, ) - : isOwnMessage - ? Border.all( - color: Theme.of( - context, - ).colorScheme.primary.withValues(alpha: 0.3), - width: 1.5, - ) - : !message.isRead && - !message.isSentMessage && - !message.isSystemMessage - ? Border.all(color: Colors.blue, width: 1.5) - : null, - boxShadow: isSarMarker + : isSarMarker + ? Border.all( + color: _getSarMarkerBorderColor(context, isDarkMode), + width: 2, + ) + : isOwnMessage + ? Border.all( + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.3), + width: 1.5, + ) + : !message.isRead && + !message.isSentMessage && + !message.isSystemMessage + ? Border.all(color: Colors.blue, width: 1.5) + : null, + boxShadow: isHighlighted ? [ BoxShadow( - color: _getSarMarkerBorderColor( - context, - isDarkMode, - ).withValues(alpha: 0.3), - blurRadius: 8, + color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.5), + blurRadius: 12, + spreadRadius: 2, offset: const Offset(0, 2), ), ] - : null, + : isSarMarker + ? [ + BoxShadow( + color: _getSarMarkerBorderColor( + context, + isDarkMode, + ).withValues(alpha: 0.3), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ] + : null, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -968,7 +1261,8 @@ class _MessageBubble extends StatelessWidget { Row( children: [ Text( - message.sarMarkerType!.emoji, + // Use custom emoji if available (for unknown types), otherwise use type emoji + message.sarCustomEmoji ?? message.sarMarkerType!.emoji, style: const TextStyle(fontSize: 28), ), const SizedBox(width: 10), @@ -977,7 +1271,10 @@ class _MessageBubble extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - message.sarMarkerType!.getLocalizedName(context), + // Show template name (sarNotes) if available, otherwise show localized type name + message.sarNotes != null && message.sarNotes!.isNotEmpty + ? message.sarNotes! + : message.sarMarkerType!.getLocalizedName(context), style: Theme.of(context).textTheme.titleSmall ?.copyWith(fontWeight: FontWeight.bold), ), @@ -997,23 +1294,6 @@ class _MessageBubble extends StatelessWidget { ), ], ), - // Display SAR notes/message if present - if (message.sarNotes != null && message.sarNotes!.isNotEmpty) ...[ - const SizedBox(height: 8), - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.surfaceVariant.withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(8), - ), - child: Text( - message.sarNotes!, - style: Theme.of(context).textTheme.bodySmall, - ), - ), - ], ] // Regular message content else diff --git a/lib/screens/sar_template_management_screen.dart b/lib/screens/sar_template_management_screen.dart new file mode 100644 index 0000000..632abbb --- /dev/null +++ b/lib/screens/sar_template_management_screen.dart @@ -0,0 +1,450 @@ +import 'package:flutter/material.dart'; +import '../models/sar_template.dart'; +import '../services/sar_template_service.dart'; +import '../widgets/sar/sar_template_edit_dialog.dart'; +import '../l10n/app_localizations.dart'; + +/// Screen for managing SAR templates +class SarTemplateManagementScreen extends StatefulWidget { + const SarTemplateManagementScreen({super.key}); + + @override + State createState() => _SarTemplateManagementScreenState(); +} + +class _SarTemplateManagementScreenState extends State { + final SarTemplateService _templateService = SarTemplateService(); + bool _isLoading = false; + + @override + void initState() { + super.initState(); + _initializeService(); + } + + Future _initializeService() async { + if (!_templateService.isInitialized) { + setState(() => _isLoading = true); + await _templateService.initialize(); + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + Future _addTemplate() async { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => SarTemplateEditDialog( + onSave: (template) async { + await _templateService.addTemplate(template); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.templateAdded), + backgroundColor: Colors.green, + ), + ); + } + }, + ), + ); + } + + Future _editTemplate(SarTemplate template) async { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => SarTemplateEditDialog( + template: template, + onSave: (updatedTemplate) async { + await _templateService.updateTemplate(template.id, updatedTemplate); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.templateUpdated), + backgroundColor: Colors.green, + ), + ); + } + }, + ), + ); + } + + Future _deleteTemplate(SarTemplate template) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(AppLocalizations.of(context)!.deleteTemplate), + content: Text( + AppLocalizations.of(context)!.deleteTemplateConfirmation(template.name), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(AppLocalizations.of(context)!.cancel), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: Text(AppLocalizations.of(context)!.delete), + ), + ], + ), + ); + + if (confirmed == true) { + await _templateService.deleteTemplate(template.id); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.templateDeleted), + backgroundColor: Colors.orange, + ), + ); + } + } + } + + Future _importFromClipboard() async { + setState(() => _isLoading = true); + + try { + final importedCount = await _templateService.importFromClipboard(); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.templatesImported(importedCount)), + backgroundColor: importedCount > 0 ? Colors.green : Colors.orange, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.importFailed(e.toString())), + backgroundColor: Colors.red, + ), + ); + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + Future _exportToClipboard() async { + try { + await _templateService.exportToClipboard(); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context)!.templatesExported(_templateService.templateCount), + ), + backgroundColor: Colors.green, + action: SnackBarAction( + label: AppLocalizations.of(context)!.ok, + textColor: Colors.white, + onPressed: () {}, + ), + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.exportFailed(e.toString())), + backgroundColor: Colors.red, + ), + ); + } + } + } + + Future _resetToDefaults() async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(AppLocalizations.of(context)!.resetToDefaults), + content: Text(AppLocalizations.of(context)!.resetToDefaultsConfirmation), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(AppLocalizations.of(context)!.cancel), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: Text(AppLocalizations.of(context)!.reset), + ), + ], + ), + ); + + if (confirmed == true) { + await _templateService.resetToDefaults(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.resetComplete), + backgroundColor: Colors.green, + ), + ); + } + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final l10n = AppLocalizations.of(context)!; + + return Scaffold( + appBar: AppBar( + title: Text(l10n.sarTemplates), + actions: [ + PopupMenuButton( + icon: const Icon(Icons.more_vert), + tooltip: 'More options', + onSelected: (value) { + switch (value) { + case 'import': + _importFromClipboard(); + break; + case 'export': + _exportToClipboard(); + break; + case 'reset': + _resetToDefaults(); + break; + } + }, + itemBuilder: (context) => [ + PopupMenuItem( + value: 'import', + child: ListTile( + leading: const Icon(Icons.download), + title: Text(l10n.importFromClipboard), + contentPadding: EdgeInsets.zero, + ), + ), + PopupMenuItem( + value: 'export', + child: ListTile( + leading: const Icon(Icons.upload), + title: Text(l10n.exportToClipboard), + contentPadding: EdgeInsets.zero, + ), + ), + const PopupMenuDivider(), + PopupMenuItem( + value: 'reset', + child: ListTile( + leading: const Icon(Icons.restart_alt), + title: Text(l10n.resetToDefaults), + contentPadding: EdgeInsets.zero, + ), + ), + ], + ), + ], + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : ListenableBuilder( + listenable: _templateService, + builder: (context, child) { + final templates = _templateService.templates; + + if (templates.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.location_searching, + size: 64, + color: colorScheme.onSurface.withValues(alpha: 0.3), + ), + const SizedBox(height: 16), + Text( + l10n.noTemplates, + style: theme.textTheme.titleMedium?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + const SizedBox(height: 8), + Text( + l10n.tapAddToCreate, + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.5), + ), + ), + ], + ), + ); + } + + return ListView.builder( + itemCount: templates.length, + itemBuilder: (context, index) { + final template = templates[index]; + return _TemplateListItem( + template: template, + onTap: () => _editTemplate(template), + onDelete: () => _deleteTemplate(template), + ); + }, + ); + }, + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: _addTemplate, + icon: const Icon(Icons.add), + label: Text(l10n.addTemplate), + ), + ); + } +} + +/// Template list item widget +class _TemplateListItem extends StatelessWidget { + final SarTemplate template; + final VoidCallback onTap; + final VoidCallback onDelete; + + const _TemplateListItem({ + required this.template, + required this.onTap, + required this.onDelete, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Dismissible( + key: Key(template.id), + direction: DismissDirection.endToStart, + background: Container( + color: Colors.red, + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 20), + child: const Icon(Icons.delete, color: Colors.white), + ), + confirmDismiss: (direction) async { + // Show confirmation dialog + return await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(AppLocalizations.of(context)!.deleteTemplate), + content: Text( + AppLocalizations.of(context)!.deleteTemplateConfirmation(template.name), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(AppLocalizations.of(context)!.cancel), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: Text(AppLocalizations.of(context)!.delete), + ), + ], + ), + ); + }, + onDismissed: (direction) => onDelete(), + child: ListTile( + onTap: onTap, + leading: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: template.color, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: template.color.withValues(alpha: 0.3), + blurRadius: 4, + spreadRadius: 1, + ), + ], + ), + child: Center( + child: Text( + template.emoji, + style: const TextStyle(fontSize: 24), + ), + ), + ), + title: Row( + children: [ + Text( + template.name, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + if (template.isDefault) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: Colors.blue.withValues(alpha: 0.5), + ), + ), + child: Text( + 'DEFAULT', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: Colors.blue.shade700, + ), + ), + ), + ], + ], + ), + subtitle: template.description.isNotEmpty + ? Text( + template.description, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.6), + ), + ) + : Text( + template.toSarMessage(), + style: TextStyle( + fontFamily: 'monospace', + fontSize: 12, + color: colorScheme.onSurface.withValues(alpha: 0.5), + ), + ), + trailing: IconButton( + icon: const Icon(Icons.delete_outline), + color: Colors.red, + onPressed: onDelete, + ), + ), + ); + } +} diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index ec3fc39..d61be15 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -12,6 +12,7 @@ import '../services/locale_preferences.dart'; import '../utils/sample_data_generator.dart'; import '../theme/app_theme.dart'; import '../l10n/app_localizations.dart'; +import 'sar_template_management_screen.dart'; class SettingsScreen extends StatefulWidget { final Function(AppThemeMode) onThemeChanged; @@ -259,27 +260,27 @@ class _SettingsScreenState extends State { showDialog( context: context, builder: (context) => AlertDialog( - title: const Row( + title: Row( children: [ - Icon(Icons.settings, size: 24), - SizedBox(width: 12), - Text('Location Permission'), + const Icon(Icons.settings, size: 24), + const SizedBox(width: 12), + Text(AppLocalizations.of(context)!.locationPermission), ], ), - content: const Text( - 'Location permission is permanently denied. Please enable it in your device settings to use GPS tracking and location sharing features.', + content: Text( + AppLocalizations.of(context)!.locationPermissionDialogContent, ), actions: [ TextButton( onPressed: () => Navigator.pop(context), - child: const Text('Cancel'), + child: Text(AppLocalizations.of(context)!.cancel), ), ElevatedButton( onPressed: () async { Navigator.pop(context); await Geolocator.openAppSettings(); }, - child: const Text('Open Settings'), + child: Text(AppLocalizations.of(context)!.openSettings), ), ], ), @@ -294,8 +295,8 @@ class _SettingsScreenState extends State { newPermission == LocationPermission.always) { // Permission granted ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Location permission granted!'), + SnackBar( + content: Text(AppLocalizations.of(context)!.locationPermissionGranted), backgroundColor: Colors.green, ), ); @@ -303,10 +304,10 @@ class _SettingsScreenState extends State { } else { // Permission denied ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Location permission is required for GPS tracking and location sharing.'), + SnackBar( + content: Text(AppLocalizations.of(context)!.locationPermissionRequiredForGps), backgroundColor: Colors.orange, - duration: Duration(seconds: 4), + duration: const Duration(seconds: 4), ), ); } @@ -314,8 +315,8 @@ class _SettingsScreenState extends State { // Already granted - show info if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Location permission is already granted.'), + SnackBar( + content: Text(AppLocalizations.of(context)!.locationPermissionAlreadyGranted), backgroundColor: Colors.blue, ), ); @@ -433,6 +434,17 @@ class _SettingsScreenState extends State { await _saveRxTxPreference(value); }, ), + Consumer( + builder: (context, appProvider, child) => SwitchListTile( + secondary: const Icon(Icons.visibility_off), + title: Text(AppLocalizations.of(context)!.simpleMode), + subtitle: Text(AppLocalizations.of(context)!.simpleModeDescription), + value: appProvider.isSimpleMode, + onChanged: (value) async { + await appProvider.toggleSimpleMode(value); + }, + ), + ), ListTile( leading: const Icon(Icons.language), title: Text(AppLocalizations.of(context)!.language), @@ -440,18 +452,32 @@ class _SettingsScreenState extends State { trailing: const Icon(Icons.chevron_right), onTap: () => _showLanguageDialog(), ), + ListTile( + leading: const Icon(Icons.location_searching), + title: Text(AppLocalizations.of(context)!.sarTemplates), + subtitle: Text(AppLocalizations.of(context)!.manageSarTemplates), + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const SarTemplateManagementScreen(), + ), + ); + }, + ), const Divider(), // Permissions Section - _buildSectionHeader('Permissions'), + _buildSectionHeader(AppLocalizations.of(context)!.permissionsSection), ListTile( leading: const Icon(Icons.location_on), - title: const Text('Location Permission'), + title: Text(AppLocalizations.of(context)!.locationPermission), subtitle: FutureBuilder( future: Geolocator.checkPermission(), builder: (context, snapshot) { if (!snapshot.hasData) { - return const Text('Checking...'); + return Text(AppLocalizations.of(context)!.checking); } final permission = snapshot.data!; String statusText; @@ -459,23 +485,23 @@ class _SettingsScreenState extends State { switch (permission) { case LocationPermission.always: - statusText = 'Granted (Always)'; + statusText = AppLocalizations.of(context)!.locationPermissionGrantedAlways; statusColor = Colors.green; break; case LocationPermission.whileInUse: - statusText = 'Granted (While In Use)'; + statusText = AppLocalizations.of(context)!.locationPermissionGrantedWhileInUse; statusColor = Colors.green; break; case LocationPermission.denied: - statusText = 'Denied - Tap to request'; + statusText = AppLocalizations.of(context)!.locationPermissionDeniedTapToRequest; statusColor = Colors.orange; break; case LocationPermission.deniedForever: - statusText = 'Permanently Denied - Open Settings'; + statusText = AppLocalizations.of(context)!.locationPermissionPermanentlyDeniedOpenSettings; statusColor = Colors.red; break; default: - statusText = 'Unknown'; + statusText = AppLocalizations.of(context)!.unknown; statusColor = Colors.grey; } @@ -906,7 +932,7 @@ class _SettingsScreenState extends State { RadioListTile( title: Row( children: [ - const Text('SAR Navy Blue'), + Text(AppLocalizations.of(context)!.sarNavyBlue), const SizedBox(width: 8), Container( width: 16, @@ -919,7 +945,7 @@ class _SettingsScreenState extends State { ), ], ), - subtitle: const Text('Professional/Operations Mode'), + subtitle: Text(AppLocalizations.of(context)!.sarNavyBlueDescription), value: AppThemeMode.sarNavyBlue, groupValue: _selectedTheme, onChanged: (value) { diff --git a/lib/services/map_marker_service.dart b/lib/services/map_marker_service.dart index 2fd300f..9c29385 100644 --- a/lib/services/map_marker_service.dart +++ b/lib/services/map_marker_service.dart @@ -184,7 +184,7 @@ class MapMarkerService { ), padding: const EdgeInsets.all(6), child: Text( - marker.type.emoji, + marker.emoji, // Use custom emoji if available style: const TextStyle(fontSize: 18), ), ), @@ -198,7 +198,7 @@ class MapMarkerService { borderRadius: BorderRadius.circular(3), ), child: Text( - marker.type.displayName, + marker.displayName, // Uses notes if available, otherwise type.displayName style: const TextStyle( color: Colors.white, fontSize: 9, diff --git a/lib/services/meshcore_ble_service.dart b/lib/services/meshcore_ble_service.dart index 5001337..8f53b97 100644 --- a/lib/services/meshcore_ble_service.dart +++ b/lib/services/meshcore_ble_service.dart @@ -401,8 +401,8 @@ class MeshCoreBleService { required double latitude, required double longitude, }) async { - // Note: This command does not return an ACK, so we use writeData (fire-and-forget) - await _commandSender.writeData(FrameBuilder.buildSetAdvertLatLon( + // This command returns OK (0x00) response, so wait for acknowledgment + await _commandSender.writeDataAndWaitForAck(FrameBuilder.buildSetAdvertLatLon( latitude: latitude, longitude: longitude, )); diff --git a/lib/services/message_destination_preferences.dart b/lib/services/message_destination_preferences.dart new file mode 100644 index 0000000..37d1994 --- /dev/null +++ b/lib/services/message_destination_preferences.dart @@ -0,0 +1,71 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +/// Service for managing message destination preferences +/// Stores the last selected recipient (channel, contact, or room) for sending messages +class MessageDestinationPreferences { + static const String _destinationTypeKey = 'message_destination_type'; + static const String _recipientPublicKeyKey = 'message_recipient_public_key'; + + /// Destination types + static const String destinationTypeChannel = 'channel'; + static const String destinationTypeContact = 'contact'; + static const String destinationTypeRoom = 'room'; + + /// Get the saved destination configuration + /// Returns a map with 'type' and optional 'publicKey' + /// Returns null if no preference is saved (defaults to public channel) + static Future?> getDestination() async { + final prefs = await SharedPreferences.getInstance(); + final type = prefs.getString(_destinationTypeKey); + + if (type == null) { + return null; // Use default (public channel) + } + + final publicKey = prefs.getString(_recipientPublicKeyKey); + + return { + 'type': type, + if (publicKey != null) 'publicKey': publicKey, + }; + } + + /// Save the selected destination + /// [type] - one of: destinationTypeChannel, destinationTypeContact, destinationTypeRoom + /// [recipientPublicKey] - hex string of recipient's public key (required for contact/room) + static Future setDestination( + String type, { + String? recipientPublicKey, + }) async { + final prefs = await SharedPreferences.getInstance(); + + await prefs.setString(_destinationTypeKey, type); + + if (recipientPublicKey != null) { + await prefs.setString(_recipientPublicKeyKey, recipientPublicKey); + } else { + await prefs.remove(_recipientPublicKeyKey); + } + } + + /// Clear the saved destination (resets to default public channel) + static Future clearDestination() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_destinationTypeKey); + await prefs.remove(_recipientPublicKeyKey); + } + + /// Get display name for destination type + static String getDestinationTypeName(String type) { + switch (type) { + case destinationTypeChannel: + return 'Channel'; + case destinationTypeContact: + return 'Contact'; + case destinationTypeRoom: + return 'Room'; + default: + return 'Unknown'; + } + } +} diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index 63062b2..9dbd361 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -20,6 +20,7 @@ class NotificationService { // Notification IDs static const int _sarNotificationId = 1000; + static const int _messageNotificationId = 2000; // Notification channels static const String _urgentChannelId = 'sar_urgent'; @@ -27,6 +28,11 @@ class NotificationService { static const String _urgentChannelDescription = 'Critical alerts for SAR markers (found persons, fires, staging areas)'; + static const String _messagesChannelId = 'messages'; + static const String _messagesChannelName = 'Messages'; + static const String _messagesChannelDescription = + 'Notifications for incoming messages from contacts and channels'; + /// Initialize notification service Future initialize() async { if (_isInitialized) return; @@ -125,8 +131,20 @@ class NotificationService { sound: RawResourceAndroidNotificationSound('notification'), ); + // Messages channel with high priority + const messagesChannel = AndroidNotificationChannel( + _messagesChannelId, + _messagesChannelName, + description: _messagesChannelDescription, + importance: Importance.high, + playSound: true, + enableVibration: true, + showBadge: true, + ); + await androidPlugin.createNotificationChannel(urgentChannel); - debugPrint('✅ [NotificationService] Created urgent notification channel'); + await androidPlugin.createNotificationChannel(messagesChannel); + debugPrint('✅ [NotificationService] Created notification channels'); } catch (e) { debugPrint('⚠️ [NotificationService] Error creating channels: $e'); } @@ -303,6 +321,93 @@ class NotificationService { } } + /// Show notification for regular message (contact or channel) + Future showMessageNotification({ + required String senderName, + required String messageText, + required bool isChannelMessage, + String? channelName, + AppLocalizations? localizations, + }) async { + if (!_isInitialized) { + debugPrint('⚠️ [NotificationService] Not initialized, skipping notification'); + return; + } + + if (!_permissionGranted) { + debugPrint('⚠️ [NotificationService] Permission not granted, skipping notification'); + return; + } + + try { + // Generate unique notification ID based on timestamp + final notificationId = _messageNotificationId + (DateTime.now().millisecondsSinceEpoch % 1000); + + // Build notification title and body + final title = isChannelMessage + ? (localizations != null + ? '${localizations.channel}: ${channelName ?? "Public"}' + : 'Channel: ${channelName ?? "Public"}') + : (localizations != null + ? '${localizations.newMessage} ${localizations.from} $senderName' + : 'New message from $senderName'); + + final body = messageText.length > 200 + ? '${messageText.substring(0, 200)}...' + : messageText; + + // Android notification details + final androidDetails = AndroidNotificationDetails( + _messagesChannelId, + _messagesChannelName, + channelDescription: _messagesChannelDescription, + importance: Importance.high, + priority: Priority.high, + ticker: title, + playSound: true, + enableVibration: true, + showWhen: true, + when: DateTime.now().millisecondsSinceEpoch, + styleInformation: BigTextStyleInformation( + body, + contentTitle: title, + summaryText: senderName, + ), + ); + + // iOS notification details + final darwinDetails = DarwinNotificationDetails( + presentAlert: true, + presentBadge: true, + presentSound: true, + sound: 'default', + threadIdentifier: isChannelMessage ? 'channel_messages' : 'direct_messages', + subtitle: senderName, + ); + + // Combined notification details + final notificationDetails = NotificationDetails( + android: androidDetails, + iOS: darwinDetails, + ); + + // Show notification + await _notificationsPlugin.show( + notificationId, + title, + body, + notificationDetails, + payload: 'message:${isChannelMessage ? "channel" : "contact"}', + ); + + debugPrint('✅ [NotificationService] Showed message notification'); + debugPrint(' Sender: $senderName'); + debugPrint(' Type: ${isChannelMessage ? "Channel" : "Direct"}'); + } catch (e) { + debugPrint('❌ [NotificationService] Error showing message notification: $e'); + } + } + /// Cancel all notifications Future cancelAll() async { try { diff --git a/lib/services/sar_template_service.dart b/lib/services/sar_template_service.dart new file mode 100644 index 0000000..e0d2126 --- /dev/null +++ b/lib/services/sar_template_service.dart @@ -0,0 +1,244 @@ +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../models/sar_template.dart'; +import '../utils/sar_message_parser.dart'; + +/// SAR Template Service - Manages SAR templates with persistence +class SarTemplateService extends ChangeNotifier { + static final SarTemplateService _instance = SarTemplateService._internal(); + factory SarTemplateService() => _instance; + SarTemplateService._internal(); + + static const String _storageKey = 'sar_templates'; + List _templates = []; + bool _initialized = false; + + /// Get all templates + List get templates => List.unmodifiable(_templates); + + /// Get default templates + List get defaultTemplates => + _templates.where((t) => t.isDefault).toList(); + + /// Get custom templates + List get customTemplates => + _templates.where((t) => !t.isDefault).toList(); + + /// Check if initialized + bool get isInitialized => _initialized; + + /// Initialize service and load templates + Future initialize() async { + if (_initialized) return; + + try { + final prefs = await SharedPreferences.getInstance(); + final jsonString = prefs.getString(_storageKey); + + if (jsonString != null && jsonString.isNotEmpty) { + // Load saved templates + final List jsonList = json.decode(jsonString); + _templates = jsonList.map((json) => SarTemplate.fromJson(json)).toList(); + + // Ensure defaults exist (in case user deleted them or version upgrade) + _ensureDefaultTemplates(); + } else { + // First time - initialize with defaults + _templates = SarTemplate.defaults; + await _saveToStorage(); + } + + _initialized = true; + notifyListeners(); + debugPrint('SarTemplateService initialized with ${_templates.length} templates'); + } catch (e) { + debugPrint('Error initializing SAR templates: $e'); + // Fallback to defaults on error + _templates = SarTemplate.defaults; + _initialized = true; + notifyListeners(); + } + } + + /// Ensure default templates exist + void _ensureDefaultTemplates() { + final defaults = SarTemplate.defaults; + final existingDefaultIds = _templates.where((t) => t.isDefault).map((t) => t.id).toSet(); + + // Add missing defaults + for (final defaultTemplate in defaults) { + if (!existingDefaultIds.contains(defaultTemplate.id)) { + _templates.insert(0, defaultTemplate); + } + } + } + + /// Save templates to storage + Future _saveToStorage() async { + try { + final prefs = await SharedPreferences.getInstance(); + final jsonList = _templates.map((t) => t.toJson()).toList(); + final jsonString = json.encode(jsonList); + await prefs.setString(_storageKey, jsonString); + debugPrint('Saved ${_templates.length} SAR templates to storage'); + } catch (e) { + debugPrint('Error saving SAR templates: $e'); + rethrow; + } + } + + /// Add new template + Future addTemplate(SarTemplate template) async { + _templates.add(template); + await _saveToStorage(); + notifyListeners(); + debugPrint('Added SAR template: ${template.name}'); + } + + /// Update existing template + Future updateTemplate(String id, SarTemplate updatedTemplate) async { + final index = _templates.indexWhere((t) => t.id == id); + if (index != -1) { + _templates[index] = updatedTemplate; + await _saveToStorage(); + notifyListeners(); + debugPrint('Updated SAR template: ${updatedTemplate.name}'); + } else { + throw Exception('Template with id $id not found'); + } + } + + /// Delete template + Future deleteTemplate(String id) async { + final template = _templates.firstWhere((t) => t.id == id); + _templates.removeWhere((t) => t.id == id); + await _saveToStorage(); + notifyListeners(); + debugPrint('Deleted SAR template: ${template.name}'); + } + + /// Get template by ID + SarTemplate? getTemplateById(String id) { + try { + return _templates.firstWhere((t) => t.id == id); + } catch (e) { + return null; + } + } + + /// Import templates from clipboard + /// Expects SAR message format (one per line): + /// S:🧑:0,0:Person found + /// S:🔥:0,0:Active fire + Future importFromClipboard() async { + try { + final clipboardData = await Clipboard.getData(Clipboard.kTextPlain); + if (clipboardData == null || clipboardData.text == null || clipboardData.text!.trim().isEmpty) { + throw Exception('Clipboard is empty'); + } + + return importFromText(clipboardData.text!); + } catch (e) { + debugPrint('Error importing from clipboard: $e'); + rethrow; + } + } + + /// Import templates from text (SAR message format) + Future importFromText(String text) async { + try { + final lines = text.split('\n').where((line) => line.trim().isNotEmpty).toList(); + int importedCount = 0; + final List errors = []; + + for (final line in lines) { + final trimmed = line.trim(); + if (!trimmed.startsWith('S:')) { + errors.add('Invalid format: $trimmed'); + continue; + } + + // Validate with parser + if (!SarMessageParser.isValidFormat(trimmed)) { + final error = SarMessageParser.getFormatError(trimmed); + errors.add(error ?? 'Invalid SAR message format'); + continue; + } + + try { + final template = SarTemplate.fromSarMessage(trimmed); + + // Check for duplicates (same emoji + name) + final isDuplicate = _templates.any((t) => + t.emoji == template.emoji && t.name == template.name + ); + + if (!isDuplicate) { + _templates.add(template); + importedCount++; + } + } catch (e) { + errors.add('Error parsing line: $trimmed - $e'); + } + } + + if (importedCount > 0) { + await _saveToStorage(); + notifyListeners(); + } + + if (errors.isNotEmpty) { + debugPrint('Import errors: ${errors.join(', ')}'); + } + + debugPrint('Imported $importedCount SAR templates'); + return importedCount; + } catch (e) { + debugPrint('Error importing templates: $e'); + rethrow; + } + } + + /// Export all templates to clipboard (SAR message format) + Future exportToClipboard() async { + try { + final sarMessages = _templates.map((t) => t.toSarMessage()).join('\n'); + await Clipboard.setData(ClipboardData(text: sarMessages)); + debugPrint('Exported ${_templates.length} templates to clipboard'); + } catch (e) { + debugPrint('Error exporting to clipboard: $e'); + rethrow; + } + } + + /// Export templates to text (SAR message format) + String exportToText() { + return _templates.map((t) => t.toSarMessage()).join('\n'); + } + + /// Reset to default templates + Future resetToDefaults() async { + _templates = SarTemplate.defaults; + await _saveToStorage(); + notifyListeners(); + debugPrint('Reset to default SAR templates'); + } + + /// Clear all templates (including defaults) + Future clearAll() async { + _templates.clear(); + await _saveToStorage(); + notifyListeners(); + debugPrint('Cleared all SAR templates'); + } + + /// Get count of templates + int get templateCount => _templates.length; + + /// Check if template exists + bool hasTemplate(String id) { + return _templates.any((t) => t.id == id); + } +} diff --git a/lib/services/tile_cache_service.dart b/lib/services/tile_cache_service.dart index 4eabe17..bd2062f 100644 --- a/lib/services/tile_cache_service.dart +++ b/lib/services/tile_cache_service.dart @@ -1,7 +1,6 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; -import 'package:flutter_map_tile_caching/custom_backend_api.dart'; import 'package:vector_map_tiles_mbtiles/vector_map_tiles_mbtiles.dart'; import 'package:mbtiles/mbtiles.dart'; import '../models/map_layer.dart'; @@ -173,6 +172,91 @@ class TileCacheService { } } + /// Export the current tile cache store to an archive file + /// + /// [outputPath] - Full path where the archive should be saved (e.g., '/path/to/export.fmtc') + /// + /// Returns the number of tiles exported + Future exportStore(String outputPath) async { + if (!_isInitialized) { + throw StateError( + 'TileCacheService not initialized. Call initialize() first.', + ); + } + + try { + final external = FMTCRoot.external(pathToArchive: outputPath); + final result = await external.export(storeNames: [_storeName]); + + debugPrint('Export completed: $result tiles exported to $outputPath'); + return result; + } catch (e) { + debugPrint('Error exporting store: $e'); + rethrow; + } + } + + /// Import a tile cache store from an archive file + /// + /// [filePath] - Path to the .fmtc archive file to import + /// [storeNames] - Optional list of store names to import (null = import all) + /// [strategy] - Conflict resolution strategy (default: merge) + /// + /// Returns a map with import statistics (e.g., tile count, stores imported) + Future> importStore( + String filePath, { + List? storeNames, + ImportConflictStrategy strategy = ImportConflictStrategy.merge, + }) async { + if (!_isInitialized) { + throw StateError( + 'TileCacheService not initialized. Call initialize() first.', + ); + } + + try { + final external = FMTCRoot.external(pathToArchive: filePath); + final result = external.import(storeNames: storeNames, strategy: strategy); + + // Wait for the import to complete and get tile count + final tileCount = await result.complete; + + // Wait for store states + final storesToStates = await result.storesToStates; + + debugPrint('Import completed: $tileCount tiles imported, ${storesToStates.length} stores'); + + // Count successful stores (those that weren't skipped) + final successfulCount = storesToStates.values.where((state) => state.name != null).length; + + return { + 'successfulStores': successfulCount, + 'tileCount': tileCount, + 'storesToStates': storesToStates, + }; + } catch (e) { + debugPrint('Error importing store: $e'); + rethrow; + } + } + + /// List all stores available in an archive file without importing + /// + /// [filePath] - Path to the .fmtc archive file to inspect + /// + /// Returns a list of store names contained in the archive + Future> listArchiveStores(String filePath) async { + try { + final external = FMTCRoot.external(pathToArchive: filePath); + final stores = await external.listStores; + debugPrint('Archive contains ${stores.length} stores: $stores'); + return stores; + } catch (e) { + debugPrint('Error listing archive stores: $e'); + rethrow; + } + } + void dispose() { _isInitialized = false; } diff --git a/lib/utils/drawing_message_parser.dart b/lib/utils/drawing_message_parser.dart index 981def2..24944e8 100644 --- a/lib/utils/drawing_message_parser.dart +++ b/lib/utils/drawing_message_parser.dart @@ -12,9 +12,13 @@ class DrawingMessageParser { } /// Parse drawing message text into MapDrawing object - /// senderName should be extracted from packet metadata + /// senderName and messageId should be extracted from packet metadata /// Returns null if parsing fails - static MapDrawing? parseDrawingMessage(String text, {String? senderName}) { + static MapDrawing? parseDrawingMessage( + String text, { + String? senderName, + String? messageId, + }) { if (!isDrawingMessage(text)) { return null; } @@ -27,8 +31,12 @@ class DrawingMessageParser { final json = jsonDecode(jsonStr) as Map; // Use ultra-compact network format parser - // Sender name comes from packet metadata, not JSON - return MapDrawing.fromNetworkJson(json, senderName: senderName); + // Sender name and message ID come from packet metadata, not JSON + return MapDrawing.fromNetworkJson( + json, + senderName: senderName, + messageId: messageId, + ); } catch (e) { return null; } diff --git a/lib/utils/sample_data_generator.dart b/lib/utils/sample_data_generator.dart index 5697e17..64d513f 100644 --- a/lib/utils/sample_data_generator.dart +++ b/lib/utils/sample_data_generator.dart @@ -142,7 +142,7 @@ class SampleDataGenerator { pathLen: 1, textType: MessageTextType.plain, senderTimestamp: timestamp.millisecondsSinceEpoch ~/ 1000, - text: 'S:🧑:${lat.toStringAsFixed(4)},${lon.toStringAsFixed(4)}', + text: 'S:🧑:${lat.toStringAsFixed(5)},${lon.toStringAsFixed(5)}', receivedAt: timestamp, isSarMarker: true, sarMarkerType: SarMarkerType.foundPerson, @@ -171,7 +171,7 @@ class SampleDataGenerator { pathLen: 1, textType: MessageTextType.plain, senderTimestamp: timestamp.millisecondsSinceEpoch ~/ 1000, - text: 'S:🔥:${lat.toStringAsFixed(4)},${lon.toStringAsFixed(4)}', + text: 'S:🔥:${lat.toStringAsFixed(5)},${lon.toStringAsFixed(5)}', receivedAt: timestamp, isSarMarker: true, sarMarkerType: SarMarkerType.fire, @@ -200,7 +200,7 @@ class SampleDataGenerator { pathLen: 1, textType: MessageTextType.plain, senderTimestamp: timestamp.millisecondsSinceEpoch ~/ 1000, - text: 'S:🏕️:${lat.toStringAsFixed(4)},${lon.toStringAsFixed(4)}', + text: 'S:🏕️:${lat.toStringAsFixed(5)},${lon.toStringAsFixed(5)}', receivedAt: timestamp, isSarMarker: true, sarMarkerType: SarMarkerType.stagingArea, @@ -236,7 +236,7 @@ class SampleDataGenerator { pathLen: 1, textType: MessageTextType.plain, senderTimestamp: timestamp.millisecondsSinceEpoch ~/ 1000, - text: 'S:📦:${lat.toStringAsFixed(4)},${lon.toStringAsFixed(4)}${notes[i % notes.length]}', + text: 'S:📦:${lat.toStringAsFixed(5)},${lon.toStringAsFixed(5)}${notes[i % notes.length]}', receivedAt: timestamp, isSarMarker: true, sarMarkerType: SarMarkerType.object, @@ -281,14 +281,14 @@ class SampleDataGenerator { // Mix regular messages and SAR markers final emergencyMessages = [ 'URGENT: Medical assistance needed at sector 4', - 'S:🧑:${center.latitude.toStringAsFixed(4)},${(center.longitude + 0.005).toStringAsFixed(4)} Adult male, conscious', + 'S:🧑:${center.latitude.toStringAsFixed(5)},${(center.longitude + 0.005).toStringAsFixed(5)} Adult male, conscious', 'Fire spotted - coordinates incoming', - 'S:🔥:${(center.latitude + 0.008).toStringAsFixed(4)},${(center.longitude + 0.003).toStringAsFixed(4)} Spreading rapidly!', + 'S:🔥:${(center.latitude + 0.008).toStringAsFixed(5)},${(center.longitude + 0.003).toStringAsFixed(5)} Spreading rapidly!', 'PRIORITY: Need helicopter support', 'Medical team en route to your location', 'Evac helicopter ETA 10 minutes', 'Emergency resolved - all clear', - 'S:🏕️:${(center.latitude - 0.002).toStringAsFixed(4)},${(center.longitude - 0.004).toStringAsFixed(4)} Emergency staging area', + 'S:🏕️:${(center.latitude - 0.002).toStringAsFixed(5)},${(center.longitude - 0.004).toStringAsFixed(5)} Emergency staging area', 'Emergency services notified and responding', ]; diff --git a/lib/utils/sar_message_parser.dart b/lib/utils/sar_message_parser.dart index e88cce1..4d4ded1 100644 --- a/lib/utils/sar_message_parser.dart +++ b/lib/utils/sar_message_parser.dart @@ -81,6 +81,9 @@ class SarMessageParser { sarMarkerType: sarInfo.type, sarGpsCoordinates: sarInfo.location, sarNotes: sarInfo.notes, // Extract and store notes + sarCustomEmoji: sarInfo.type == SarMarkerType.unknown + ? sarInfo.emoji // Preserve custom emoji for unknown types + : null, ); } diff --git a/lib/widgets/contacts/contact_tile.dart b/lib/widgets/contacts/contact_tile.dart index 38f4d44..a60cce0 100644 --- a/lib/widgets/contacts/contact_tile.dart +++ b/lib/widgets/contacts/contact_tile.dart @@ -8,6 +8,7 @@ import '../../models/room_login_state.dart'; import '../../providers/connection_provider.dart'; import '../../providers/contacts_provider.dart'; import '../../providers/map_provider.dart'; +import '../../providers/app_provider.dart'; import 'direct_message_sheet.dart'; import 'room_login_sheet.dart'; import '../../utils/toast_logger.dart'; @@ -18,6 +19,7 @@ class ContactTile extends StatelessWidget { final Position? currentPosition; final double Function(double, double, double, double)? calculateDistance; final String Function(double)? formatDistance; + final VoidCallback? onNavigateToMap; const ContactTile({ super.key, @@ -25,10 +27,14 @@ class ContactTile extends StatelessWidget { this.currentPosition, this.calculateDistance, this.formatDistance, + this.onNavigateToMap, }); @override Widget build(BuildContext context) { + final appProvider = context.watch(); + final isSimpleMode = appProvider.isSimpleMode; + final hasTelemetry = contact.telemetry != null && contact.telemetry!.isRecent; final battery = contact.displayBattery; final location = contact.displayLocation; @@ -113,8 +119,8 @@ class ContactTile extends StatelessWidget { overflow: TextOverflow.ellipsis, ), ), - // Battery indicator - if (battery != null) ...[ + // Battery indicator - hidden in simple mode + if (!isSimpleMode && battery != null) ...[ const SizedBox(width: 4), Icon( _getBatteryIcon(battery), @@ -130,8 +136,8 @@ class ContactTile extends StatelessWidget { ), ), ], - // Connection type indicator (direct/flood) - shown for all contact types - if (contact.type != ContactType.channel) ...[ + // Connection type indicator (direct/flood) - hidden in simple mode + if (!isSimpleMode && contact.type != ContactType.channel) ...[ const SizedBox(width: 4), Container( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), @@ -168,129 +174,186 @@ class ContactTile extends StatelessWidget { ], ], ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 4), - // Room login status badges - if (roomLoginState != null && roomLoginState.isLoggedIn) ...[ - Row( + subtitle: isSimpleMode + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (roomLoginState.isAdmin) - Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), - decoration: BoxDecoration( - color: Colors.red.withOpacity(0.2), - borderRadius: BorderRadius.circular(4), - border: Border.all(color: Colors.red, width: 1), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.admin_panel_settings, size: 10, color: Colors.red), - const SizedBox(width: 2), - Text( - AppLocalizations.of(context)!.admin, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: Colors.red, - fontWeight: FontWeight.bold, - fontSize: 10, - ), - ), - ], - ), - ), - if (roomLoginState.isAdmin) const SizedBox(width: 4), - Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), - decoration: BoxDecoration( - color: Colors.green.withOpacity(0.2), - borderRadius: BorderRadius.circular(4), - border: Border.all(color: Colors.green, width: 1), - ), - child: Row( - mainAxisSize: MainAxisSize.min, + const SizedBox(height: 4), + // Simple mode: Only show location and distance + if (location != null) ...[ + Row( children: [ - const Icon(Icons.check_circle, size: 10, color: Colors.green), - const SizedBox(width: 2), - Text( - AppLocalizations.of(context)!.loggedIn, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: Colors.green, - fontWeight: FontWeight.bold, - fontSize: 10, + const Icon(Icons.location_on, size: 12, color: Colors.blue), + const SizedBox(width: 4), + Expanded( + child: Text( + 'GPS: ${location.latitude.toStringAsFixed(5)}, ${location.longitude.toStringAsFixed(5)}', + style: Theme.of(context).textTheme.labelSmall, + overflow: TextOverflow.ellipsis, ), ), ], ), - ), - ], - ), - const SizedBox(height: 4), - ], - // Last seen + GPS info combined - Row( - children: [ - Icon( - Icons.access_time, - size: 12, - color: contact.isRecentlySeen ? Colors.green : Colors.grey, - ), - const SizedBox(width: 4), - Text( - contact.timeSinceLastSeen, - style: Theme.of(context).textTheme.labelSmall, - ), - if (location != null) ...[ - const SizedBox(width: 8), - const Text('•', style: TextStyle(color: Colors.grey)), - const SizedBox(width: 8), - if (hasTelemetry) - const Icon(Icons.sensors, size: 12, color: Colors.green) - else - const Icon(Icons.sensors_off, size: 12, color: Colors.grey), - const SizedBox(width: 4), - Expanded( - child: Text( - 'GPS: ${location.latitude.toStringAsFixed(4)}, ${location.longitude.toStringAsFixed(4)}', - style: Theme.of(context).textTheme.labelSmall, - overflow: TextOverflow.ellipsis, + if (distanceText != null) ...[ + const SizedBox(height: 4), + Row( + children: [ + const Icon(Icons.straighten, size: 12, color: Colors.blue), + const SizedBox(width: 4), + Text( + '${AppLocalizations.of(context)!.distance}: $distanceText', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Colors.blue, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ], + ] else + Text( + AppLocalizations.of(context)!.noGpsData, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Colors.grey, + ), ), - ), - ] else ...[ - const SizedBox(width: 8), - const Text('•', style: TextStyle(color: Colors.grey)), - const SizedBox(width: 8), - const Icon(Icons.sensors_off, size: 12, color: Colors.grey), - const SizedBox(width: 4), - Text( - AppLocalizations.of(context)!.noGpsData, - style: Theme.of(context).textTheme.labelSmall, - ), ], - ], - ), - // Distance info (new row) - if (distanceText != null) ...[ - const SizedBox(height: 4), - Row( + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Icon(Icons.straighten, size: 12, color: Colors.blue), - const SizedBox(width: 4), - Text( - '${AppLocalizations.of(context)!.distance}: $distanceText', - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: Colors.blue, - fontWeight: FontWeight.w500, + const SizedBox(height: 4), + // Room login status badges + if (roomLoginState != null && roomLoginState.isLoggedIn) ...[ + Row( + children: [ + if (roomLoginState.isAdmin) + Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + decoration: BoxDecoration( + color: Colors.red.withOpacity(0.2), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Colors.red, width: 1), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.admin_panel_settings, size: 10, color: Colors.red), + const SizedBox(width: 2), + Text( + AppLocalizations.of(context)!.admin, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Colors.red, + fontWeight: FontWeight.bold, + fontSize: 10, + ), + ), + ], + ), + ), + if (roomLoginState.isAdmin) const SizedBox(width: 4), + Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + decoration: BoxDecoration( + color: Colors.green.withOpacity(0.2), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Colors.green, width: 1), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.check_circle, size: 10, color: Colors.green), + const SizedBox(width: 2), + Text( + AppLocalizations.of(context)!.loggedIn, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Colors.green, + fontWeight: FontWeight.bold, + fontSize: 10, + ), + ), + ], + ), + ), + ], ), + const SizedBox(height: 4), + ], + // Last seen + GPS info combined + Row( + children: [ + Icon( + Icons.access_time, + size: 12, + color: contact.isRecentlySeen ? Colors.green : Colors.grey, + ), + const SizedBox(width: 4), + Text( + contact.timeSinceLastSeen, + style: Theme.of(context).textTheme.labelSmall, + ), + if (location != null) ...[ + const SizedBox(width: 8), + const Text('•', style: TextStyle(color: Colors.grey)), + const SizedBox(width: 8), + if (hasTelemetry) + const Icon(Icons.sensors, size: 12, color: Colors.green) + else + const Icon(Icons.sensors_off, size: 12, color: Colors.grey), + const SizedBox(width: 4), + Expanded( + child: Text( + 'GPS: ${location.latitude.toStringAsFixed(5)}, ${location.longitude.toStringAsFixed(5)}', + style: Theme.of(context).textTheme.labelSmall, + overflow: TextOverflow.ellipsis, + ), + ), + ] else ...[ + const SizedBox(width: 8), + const Text('•', style: TextStyle(color: Colors.grey)), + const SizedBox(width: 8), + const Icon(Icons.sensors_off, size: 12, color: Colors.grey), + const SizedBox(width: 4), + Text( + AppLocalizations.of(context)!.noGpsData, + style: Theme.of(context).textTheme.labelSmall, + ), + ], + ], ), + // Distance info (new row) + if (distanceText != null) ...[ + const SizedBox(height: 4), + Row( + children: [ + const Icon(Icons.straighten, size: 12, color: Colors.blue), + const SizedBox(width: 4), + Text( + '${AppLocalizations.of(context)!.distance}: $distanceText', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Colors.blue, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ], ], ), - ], - ], - ), trailing: null, - onTap: () => _showContactDetails(context, contact), + onTap: () { + // In simple mode, tap directly opens message sheet for chat contacts + if (isSimpleMode && contact.type == ContactType.chat) { + _showDirectMessageDialog(context, contact); + } else if (isSimpleMode && contact.type == ContactType.repeater) { + // In simple mode, tapping a repeater jumps to the map + _jumpToMapForRepeater(context, contact); + } else if (isSimpleMode && contact.type == ContactType.room && !contact.isPublicChannel) { + _showRoomLoginDialog(context, contact); + } else { + _showContactDetails(context, contact); + } + }, onLongPress: () async { final connectionProvider = context.read(); @@ -360,6 +423,29 @@ class ContactTile extends StatelessWidget { ); } + void _jumpToMapForRepeater(BuildContext context, Contact contact) { + final location = contact.displayLocation; + if (location != null) { + final mapProvider = context.read(); + + // Navigate to map location + mapProvider.navigateToLocation( + location: LatLng(location.latitude, location.longitude), + zoom: 15.0, + animate: true, + ); + + // Switch to map tab using callback + onNavigateToMap?.call(); + } else { + // No location available, just show toast + ToastLogger.info( + context, + 'Repeater ${contact.displayName} has no location data', + ); + } + } + void _showDeleteConfirmation(BuildContext context, Contact contact) { showDialog( context: context, @@ -587,8 +673,8 @@ class ContactTile extends StatelessWidget { ); Navigator.pop(context); - // Switch to map tab (assuming it's index 2) - DefaultTabController.of(context).animateTo(2); + // Switch to map tab using callback + onNavigateToMap?.call(); }, icon: const Icon(Icons.map, size: 18), label: Text(AppLocalizations.of(context)!.viewOnMap), diff --git a/lib/widgets/contacts/direct_message_sheet.dart b/lib/widgets/contacts/direct_message_sheet.dart index e0c3f1c..77f0cee 100644 --- a/lib/widgets/contacts/direct_message_sheet.dart +++ b/lib/widgets/contacts/direct_message_sheet.dart @@ -1,12 +1,14 @@ -import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'package:geolocator/geolocator.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; import '../../models/contact.dart'; import '../../models/message.dart'; import '../../providers/connection_provider.dart'; import '../../providers/messages_provider.dart'; +import '../../providers/app_provider.dart'; import '../../utils/toast_logger.dart'; import '../../l10n/app_localizations.dart'; @@ -174,6 +176,9 @@ class _DirectMessageSheetState extends State { Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; + final appProvider = context.watch(); + final isSimpleMode = appProvider.isSimpleMode; + final contactLocation = widget.contact.displayLocation; return Container( height: MediaQuery.of(context).size.height * 0.9, @@ -222,35 +227,90 @@ class _DirectMessageSheetState extends State { ), ), - // Info banner - Container( - margin: const EdgeInsets.symmetric(horizontal: 16), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(8), - ), - child: Row( - children: [ - Icon(Icons.info_outline, color: Theme.of(context).colorScheme.onPrimaryContainer), - const SizedBox(width: 12), - Expanded( - child: Text( - AppLocalizations.of(context)!.directMessageInfo(widget.contact.displayName), - style: TextStyle( - color: Theme.of(context).colorScheme.onPrimaryContainer, - fontSize: 13, + // Mini map in simple mode (scrollable content) + Expanded( + child: SingleChildScrollView( + child: Column( + children: [ + const SizedBox(height: 16), + if (isSimpleMode && contactLocation != null) ...[ + GestureDetector( + onTap: () { + // Hide keyboard when tapping on map + _focusNode.unfocus(); + }, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + height: 200, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: colorScheme.outline), + ), + clipBehavior: Clip.antiAlias, + child: FlutterMap( + options: MapOptions( + initialCenter: LatLng( + contactLocation.latitude, + contactLocation.longitude, + ), + initialZoom: 13.0, + interactionOptions: const InteractionOptions( + flags: InteractiveFlag.pinchZoom | InteractiveFlag.drag, + ), + ), + children: [ + TileLayer( + urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: 'com.meshcore.sar', + ), + MarkerLayer( + markers: [ + Marker( + point: LatLng( + contactLocation.latitude, + contactLocation.longitude, + ), + width: 40, + height: 40, + child: Icon( + Icons.location_on, + color: colorScheme.primary, + size: 40, + ), + ), + ], + ), + ], + ), + ), ), - ), - ), - ], + const SizedBox(height: 8), + // Location coordinates + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.gps_fixed, size: 14, color: colorScheme.onSurfaceVariant), + const SizedBox(width: 4), + Text( + '${contactLocation.latitude.toStringAsFixed(5)}, ${contactLocation.longitude.toStringAsFixed(5)}', + style: TextStyle( + color: colorScheme.onSurfaceVariant, + fontSize: 12, + fontFamily: 'monospace', + ), + ), + ], + ), + ), + const SizedBox(height: 16), + ], + ], + ), ), ), - const SizedBox(height: 16), - - const Spacer(), - // Message input Container( padding: EdgeInsets.only( @@ -321,7 +381,7 @@ class _DirectMessageSheetState extends State { OutlinedButton.icon( onPressed: _insertCurrentLocation, icon: const Icon(Icons.my_location, size: 18), - label: const Text('Location'), + label: Text(AppLocalizations.of(context)!.myLocation), style: OutlinedButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), side: BorderSide(color: colorScheme.outline), diff --git a/lib/widgets/map/compass/compass_sar_list.dart b/lib/widgets/map/compass/compass_sar_list.dart index 40aa46e..84c3653 100644 --- a/lib/widgets/map/compass/compass_sar_list.dart +++ b/lib/widgets/map/compass/compass_sar_list.dart @@ -124,7 +124,7 @@ class CompassSarList extends StatelessWidget { color: markerColor, size: 24, ), - title: Text(marker.type.displayName), + title: Text(marker.displayName), subtitle: Text( '${_bearingToCardinal(bearing)} • ${_formatDistance(distance)} • ${marker.timeAgo}', style: Theme.of(context).textTheme.bodySmall, diff --git a/lib/widgets/map/detailed_compass_dialog.dart b/lib/widgets/map/detailed_compass_dialog.dart index c2e1f30..5d519ed 100644 --- a/lib/widgets/map/detailed_compass_dialog.dart +++ b/lib/widgets/map/detailed_compass_dialog.dart @@ -534,7 +534,7 @@ class _DetailedCompassDialogState extends State { additionalInfo = 'Battery: ${_selectedContact!.telemetry!.batteryPercentage!.round()}%'; } } else if (_selectedSarMarker != null) { - title = _selectedSarMarker!.type.displayName; + title = _selectedSarMarker!.displayName; targetLocation = _selectedSarMarker!.location; additionalInfo = _selectedSarMarker!.timeAgo; @@ -664,21 +664,21 @@ class _DetailedCompassDialogState extends State { children: [ _buildLargeInfoCard( context, - 'Distance', + AppLocalizations.of(context)!.distance, _formatDistance(distance), Icons.straighten, color, ), _buildLargeInfoCard( context, - 'Bearing', + AppLocalizations.of(context)!.bearing, '${bearing.round()}°', Icons.navigation, color, ), _buildLargeInfoCard( context, - 'Direction', + AppLocalizations.of(context)!.direction, _bearingToCardinal(bearing), Icons.explore, color, diff --git a/lib/widgets/map/drawing_layer.dart b/lib/widgets/map/drawing_layer.dart index e74c5c0..6f71ad7 100644 --- a/lib/widgets/map/drawing_layer.dart +++ b/lib/widgets/map/drawing_layer.dart @@ -8,11 +8,13 @@ import '../../l10n/app_localizations.dart'; class DrawingLayer extends StatelessWidget { final List drawings; final MapDrawing? previewDrawing; + final bool isSimpleMode; const DrawingLayer({ super.key, required this.drawings, this.previewDrawing, + this.isSimpleMode = false, }); @override @@ -45,8 +47,9 @@ class DrawingLayer extends StatelessWidget { opacity = 0.6; strokeWidth = 4.0; } else if (drawing.isReceived) { - // Received drawing from another node (thinner, more transparent) - opacity = 0.7; + // Received drawing from another node + // In simple mode: solid (opacity 1.0), in normal mode: translucent (0.7) + opacity = isSimpleMode ? 1.0 : 0.7; strokeWidth = 3.0; } else { // Local drawing (solid line, normal thickness) @@ -82,13 +85,17 @@ class DrawingLayer extends StatelessWidget { class DrawingMarkersLayer extends StatelessWidget { final List drawings; final Function(String drawingId)? onDeleteDrawing; + final Function(MapDrawing drawing)? onTapDrawing; final bool showDeleteButtons; + final bool isSimpleMode; const DrawingMarkersLayer({ super.key, required this.drawings, this.onDeleteDrawing, + this.onTapDrawing, this.showDeleteButtons = false, + this.isSimpleMode = false, }); @override @@ -134,49 +141,64 @@ class DrawingMarkersLayer extends StatelessWidget { ), ), ); - } else if (drawing.isReceived && drawing.senderName != null) { - // Show sender badge for received drawings (when not in drawing mode) + } else if (drawing.isReceived && drawing.senderName != null && !isSimpleMode) { + // Show sender badge for received drawings (when not in drawing mode and not in simple mode) + // Make it tappable if message ID is available markers.add( Marker( point: centerPoint, width: 120, height: 30, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: drawing.color.withValues(alpha: 0.9), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.white, width: 1.5), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.3), - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.person, - color: Colors.white, - size: 14, - ), - const SizedBox(width: 4), - Flexible( - child: Text( - drawing.senderName!, - style: const TextStyle( - color: Colors.white, - fontSize: 11, - fontWeight: FontWeight.bold, - ), - overflow: TextOverflow.ellipsis, - maxLines: 1, + child: GestureDetector( + onTap: drawing.messageId != null && onTapDrawing != null + ? () => onTapDrawing!(drawing) + : null, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: drawing.color.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.white, width: 1.5), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 4, + offset: const Offset(0, 2), ), - ), - ], + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.person, + color: Colors.white, + size: 14, + ), + const SizedBox(width: 4), + Flexible( + child: Text( + drawing.senderName!, + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.bold, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), + // Add indicator that this is tappable + if (drawing.messageId != null && onTapDrawing != null) ...[ + const SizedBox(width: 4), + const Icon( + Icons.arrow_forward_ios, + color: Colors.white, + size: 10, + ), + ], + ], + ), ), ), ), diff --git a/lib/widgets/map/drawing_toolbar.dart b/lib/widgets/map/drawing_toolbar.dart index a25716c..5163b50 100644 --- a/lib/widgets/map/drawing_toolbar.dart +++ b/lib/widgets/map/drawing_toolbar.dart @@ -201,6 +201,43 @@ class DrawingToolbar extends StatelessWidget { drawingProvider.setDrawingMode(DrawingMode.rectangle); }, ), + const Divider(), + // Toggle received drawings visibility + SwitchListTile( + secondary: Icon( + drawingProvider.showReceivedDrawings + ? Icons.visibility + : Icons.visibility_off, + ), + title: Text(AppLocalizations.of(context)!.showReceivedDrawings), + subtitle: Text( + drawingProvider.showReceivedDrawings + ? AppLocalizations.of(context)!.showingAllDrawings + : AppLocalizations.of(context)!.showingOnlyYourDrawings, + ), + value: drawingProvider.showReceivedDrawings, + onChanged: (value) { + drawingProvider.toggleReceivedDrawings(); + }, + ), + // Toggle SAR markers visibility + SwitchListTile( + secondary: Icon( + drawingProvider.showSarMarkers + ? Icons.pin_drop + : Icons.pin_drop_outlined, + ), + title: Text(AppLocalizations.of(context)!.showSarMarkers), + subtitle: Text( + drawingProvider.showSarMarkers + ? AppLocalizations.of(context)!.showingSarMarkers + : AppLocalizations.of(context)!.hidingSarMarkers, + ), + value: drawingProvider.showSarMarkers, + onChanged: (value) { + drawingProvider.toggleSarMarkers(); + }, + ), if (drawingProvider.drawings.isNotEmpty) ...[ const Divider(), ListTile( diff --git a/lib/widgets/map_markers.dart b/lib/widgets/map_markers.dart index cfece21..c35e038 100644 --- a/lib/widgets/map_markers.dart +++ b/lib/widgets/map_markers.dart @@ -158,7 +158,7 @@ class MapMarkers { ), padding: const EdgeInsets.all(6), child: Text( - marker.type.emoji, + marker.emoji, // Use custom emoji if available style: const TextStyle(fontSize: 18), ), ), @@ -171,16 +171,27 @@ class MapMarkers { color: Colors.black.withOpacity(0.7), borderRadius: BorderRadius.circular(3), ), - child: Text( - marker.type.getLocalizedName(context), - style: const TextStyle( - color: Colors.white, - fontSize: 9, - fontWeight: FontWeight.bold, - ), - overflow: TextOverflow.ellipsis, - maxLines: 1, - textAlign: TextAlign.center, + child: Builder( + builder: (context) { + // Debug: Print what we're actually displaying + debugPrint('🗺️ [MapMarker] Displaying SAR marker:'); + debugPrint(' marker.notes: "${marker.notes}"'); + debugPrint(' marker.type: ${marker.type}'); + debugPrint(' marker.type.displayName: ${marker.type.displayName}'); + debugPrint(' marker.displayName: ${marker.displayName}'); + + return Text( + marker.displayName, + style: const TextStyle( + color: Colors.white, + fontSize: 9, + fontWeight: FontWeight.bold, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, + textAlign: TextAlign.center, + ); + }, ), ), ], @@ -252,9 +263,9 @@ class MapMarkers { builder: (context) => AlertDialog( title: Row( children: [ - Text(marker.type.emoji, style: const TextStyle(fontSize: 24)), + Text(marker.emoji, style: const TextStyle(fontSize: 24)), // Use custom emoji if available const SizedBox(width: 8), - Expanded(child: Text(marker.type.getLocalizedName(context))), + Expanded(child: Text(marker.displayName)), ], ), content: Column( diff --git a/lib/widgets/messages/recipient_selector_sheet.dart b/lib/widgets/messages/recipient_selector_sheet.dart new file mode 100644 index 0000000..e188e78 --- /dev/null +++ b/lib/widgets/messages/recipient_selector_sheet.dart @@ -0,0 +1,344 @@ +import 'package:flutter/material.dart'; +import '../../models/contact.dart'; +import '../../l10n/app_localizations.dart'; + +/// Bottom sheet for selecting message recipient (channel, contact, or room) +class RecipientSelectorSheet extends StatefulWidget { + final List contacts; + final List rooms; + final String? currentDestinationType; + final String? currentRecipientPublicKey; + final Function(String type, Contact? recipient) onSelect; + + const RecipientSelectorSheet({ + super.key, + required this.contacts, + required this.rooms, + this.currentDestinationType, + this.currentRecipientPublicKey, + required this.onSelect, + }); + + @override + State createState() => _RecipientSelectorSheetState(); +} + +class _RecipientSelectorSheetState extends State { + final TextEditingController _searchController = TextEditingController(); + String _searchQuery = ''; + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + List _filterContacts(List contacts) { + if (_searchQuery.isEmpty) return contacts; + final query = _searchQuery.toLowerCase(); + return contacts.where((contact) { + final name = contact.displayName?.toLowerCase() ?? contact.advName.toLowerCase(); + return name.contains(query); + }).toList(); + } + + bool _isSelected(String type, Contact? contact) { + if (widget.currentDestinationType != type) return false; + if (type == 'channel') return true; + if (contact == null) return false; + return contact.publicKeyHex == widget.currentRecipientPublicKey; + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final filteredContacts = _filterContacts(widget.contacts); + final filteredRooms = _filterContacts(widget.rooms); + + return Container( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.8, + ), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Header + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: Theme.of(context).dividerColor, + width: 1, + ), + ), + ), + child: Row( + children: [ + Text( + l10n.selectRecipient, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + tooltip: l10n.close, + ), + ], + ), + ), + + // Search field + Padding( + padding: const EdgeInsets.all(16), + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: l10n.searchRecipients, + prefixIcon: const Icon(Icons.search), + suffixIcon: _searchQuery.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + setState(() { + _searchQuery = ''; + }); + }, + ) + : null, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + onChanged: (value) { + setState(() { + _searchQuery = value; + }); + }, + ), + ), + + // Recipients list + Flexible( + child: ListView( + shrinkWrap: true, + children: [ + // Public Channel option + _buildRecipientTile( + context: context, + icon: Icons.public, + title: l10n.publicChannel, + subtitle: l10n.broadcastToAllNearby, + isSelected: _isSelected('channel', null), + onTap: () { + widget.onSelect('channel', null); + Navigator.pop(context); + }, + ), + + const Divider(), + + // Contacts section + if (widget.contacts.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: Text( + l10n.contacts, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + ), + if (filteredContacts.isEmpty) + Padding( + padding: const EdgeInsets.all(16), + child: Text( + l10n.noContactsFound, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).disabledColor, + fontStyle: FontStyle.italic, + ), + textAlign: TextAlign.center, + ), + ) + else + ...filteredContacts.map((contact) { + return _buildRecipientTile( + context: context, + icon: Icons.person, + title: contact.displayName ?? contact.advName, + subtitle: contact.publicKeyShort, + emoji: contact.roleEmoji, + isSelected: _isSelected('contact', contact), + onTap: () { + widget.onSelect('contact', contact); + Navigator.pop(context); + }, + ); + }), + ], + + const Divider(), + + // Rooms section + if (widget.rooms.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: Text( + l10n.rooms, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + ), + if (filteredRooms.isEmpty) + Padding( + padding: const EdgeInsets.all(16), + child: Text( + l10n.noRoomsFound, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).disabledColor, + fontStyle: FontStyle.italic, + ), + textAlign: TextAlign.center, + ), + ) + else + ...filteredRooms.map((room) { + return _buildRecipientTile( + context: context, + icon: Icons.meeting_room, + title: room.displayName ?? room.advName, + subtitle: room.publicKeyShort, + emoji: room.roleEmoji, + isSelected: _isSelected('room', room), + onTap: () { + widget.onSelect('room', room); + Navigator.pop(context); + }, + ); + }), + ], + + // Empty state + if (widget.contacts.isEmpty && widget.rooms.isEmpty) ...[ + Padding( + padding: const EdgeInsets.all(32), + child: Column( + children: [ + Icon( + Icons.people_outline, + size: 64, + color: Theme.of(context).disabledColor, + ), + const SizedBox(height: 16), + Text( + l10n.noContactsOrRoomsAvailable, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Theme.of(context).disabledColor, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + l10n.messagesWillBeSentToPublicChannel, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).disabledColor, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ], + + const SizedBox(height: 16), + ], + ), + ), + ], + ), + ); + } + + Widget _buildRecipientTile({ + required BuildContext context, + required IconData icon, + required String title, + required String subtitle, + String? emoji, + required bool isSelected, + required VoidCallback onTap, + }) { + return ListTile( + leading: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: isSelected + ? Theme.of(context).colorScheme.primaryContainer + : Theme.of(context).colorScheme.surfaceVariant, + borderRadius: BorderRadius.circular(20), + ), + child: Icon( + icon, + color: isSelected + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + title: Row( + children: [ + if (emoji != null && emoji.isNotEmpty) ...[ + Text(emoji, style: const TextStyle(fontSize: 16)), + const SizedBox(width: 8), + ], + Expanded( + child: Text( + title, + style: TextStyle( + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + ), + ), + ), + ], + ), + subtitle: Text( + subtitle, + style: const TextStyle( + fontSize: 12, + fontFamily: 'monospace', + ).copyWith( + color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.6), + ), + ), + trailing: isSelected + ? Icon( + Icons.check_circle, + color: Theme.of(context).colorScheme.primary, + ) + : null, + onTap: onTap, + ); + } +} diff --git a/lib/widgets/messages/sar_update_sheet.dart b/lib/widgets/messages/sar_update_sheet.dart index 1d8515b..de8181f 100644 --- a/lib/widgets/messages/sar_update_sheet.dart +++ b/lib/widgets/messages/sar_update_sheet.dart @@ -4,14 +4,15 @@ import 'package:provider/provider.dart'; import 'package:geolocator/geolocator.dart'; import '../../providers/contacts_provider.dart'; import '../../models/contact.dart'; -import '../../models/sar_marker.dart'; +import '../../models/sar_template.dart'; import '../../services/validation_service.dart'; +import '../../services/sar_template_service.dart'; import '../../l10n/app_localizations.dart'; /// SAR Update Sheet - Modal bottom sheet for creating and sending SAR markers /// This widget is public so it can be used from both messages_tab.dart and map_tab.dart class SarUpdateSheet extends StatefulWidget { - final Future Function(SarMarkerType, Position, String?, Uint8List?, bool) onSend; + final Future Function(String emoji, String name, Position, Uint8List?, bool) onSend; final Position? prePopulatedPosition; final bool allowLocationUpdate; @@ -27,7 +28,9 @@ class SarUpdateSheet extends StatefulWidget { } class _SarUpdateSheetState extends State { - SarMarkerType _selectedType = SarMarkerType.foundPerson; + SarTemplate? _selectedTemplate; + List _templates = []; + final SarTemplateService _templateService = SarTemplateService(); Position? _currentPosition; bool _loadingLocation = false; String? _locationError; @@ -37,6 +40,7 @@ class _SarUpdateSheetState extends State { @override void initState() { super.initState(); + _initializeTemplates(); // Use pre-populated position if provided, otherwise get current location if (widget.prePopulatedPosition != null) { _currentPosition = widget.prePopulatedPosition; @@ -46,6 +50,21 @@ class _SarUpdateSheetState extends State { _setDefaultDestination(); } + Future _initializeTemplates() async { + if (!_templateService.isInitialized) { + await _templateService.initialize(); + } + if (mounted) { + setState(() { + _templates = _templateService.templates; + // Select first template by default + if (_templates.isNotEmpty) { + _selectedTemplate = _templates.first; + } + }); + } + } + void _setDefaultDestination() { // Set default to first room, or first channel if no rooms exist WidgetsBinding.instance.addPostFrameCallback((_) { @@ -140,19 +159,23 @@ class _SarUpdateSheetState extends State { Widget build(BuildContext context) { // Get keyboard height to adjust padding final keyboardHeight = MediaQuery.of(context).viewInsets.bottom; + final bottomSafeArea = MediaQuery.of(context).padding.bottom; final theme = Theme.of(context); final colorScheme = theme.colorScheme; - return Container( - height: MediaQuery.of(context).size.height * 0.9, - decoration: BoxDecoration( - color: colorScheme.surface, - borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), - ), - child: Column( - children: [ - // Header - Container( + return AnimatedPadding( + padding: EdgeInsets.only(bottom: keyboardHeight), + duration: const Duration(milliseconds: 100), + child: Container( + height: MediaQuery.of(context).size.height * 0.9, + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + children: [ + // Header + Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: colorScheme.surfaceContainerHighest, @@ -196,7 +219,9 @@ class _SarUpdateSheetState extends State { left: 16, right: 16, top: 16, - bottom: keyboardHeight > 0 ? keyboardHeight + 16 : 16, + // Add bottom padding for button area (button + padding + safe area) + // Button height ~48px + container padding 32px + safe area + bottom: 80 + bottomSafeArea, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -211,30 +236,17 @@ class _SarUpdateSheetState extends State { ), ), const SizedBox(height: 12), - MarkerTypeChip( - type: SarMarkerType.foundPerson, - isSelected: _selectedType == SarMarkerType.foundPerson, - onTap: () => setState(() => _selectedType = SarMarkerType.foundPerson), - ), - const SizedBox(height: 8), - MarkerTypeChip( - type: SarMarkerType.fire, - isSelected: _selectedType == SarMarkerType.fire, - onTap: () => setState(() => _selectedType = SarMarkerType.fire), - ), - const SizedBox(height: 8), - MarkerTypeChip( - type: SarMarkerType.stagingArea, - isSelected: _selectedType == SarMarkerType.stagingArea, - onTap: () => setState(() => _selectedType = SarMarkerType.stagingArea), - ), - const SizedBox(height: 8), - MarkerTypeChip( - type: SarMarkerType.object, - isSelected: _selectedType == SarMarkerType.object, - onTap: () => setState(() => _selectedType = SarMarkerType.object), - ), - const SizedBox(height: 24), + ..._templates.map((template) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: TemplateChip( + template: template, + isSelected: _selectedTemplate?.id == template.id, + onTap: () => setState(() => _selectedTemplate = template), + ), + ); + }).toList(), + const SizedBox(height: 16), // Destination selection (compact dropdown with rooms and channel) Text( @@ -595,35 +607,28 @@ class _SarUpdateSheetState extends State { // Bottom action button Container( padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.surface, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 4, + offset: const Offset(0, -2), + ), + ], + ), child: SafeArea( top: false, child: SizedBox( width: double.infinity, child: ElevatedButton.icon( - onPressed: _currentPosition == null || _selectedContact == null + onPressed: _currentPosition == null || _selectedContact == null || _selectedTemplate == null ? null : () async { final validator = ValidationService(); - - // Validate coordinates - final coordResult = validator.validateCoordinates( - _currentPosition!.latitude, - _currentPosition!.longitude, - ); - if (!coordResult.isValid) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(coordResult.errorMessage!), - backgroundColor: Colors.red, - ), - ); - } - return; - } + final notes = _notesController.text.trim(); // Validate notes length if provided - final notes = _notesController.text.trim(); if (notes.isNotEmpty) { final notesResult = validator.validateName( notes, @@ -642,6 +647,23 @@ class _SarUpdateSheetState extends State { } } + // Validate coordinates + final coordResult = validator.validateCoordinates( + _currentPosition!.latitude, + _currentPosition!.longitude, + ); + if (!coordResult.isValid) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(coordResult.errorMessage!), + backgroundColor: Colors.red, + ), + ); + } + return; + } + // Validate location accuracy (warn if >50m) if (_currentPosition!.accuracy != null && _currentPosition!.accuracy! > 50.0) { @@ -669,10 +691,21 @@ class _SarUpdateSheetState extends State { if (shouldContinue != true) return; } + // Combine template name with optional notes + String displayText; + if (notes.isNotEmpty) { + // Include both template name and custom notes + displayText = '${_selectedTemplate!.name} - $notes'; + } else { + // Just the template name + displayText = _selectedTemplate!.name; + } + + // Send SAR marker with emoji and display text await widget.onSend( - _selectedType, + _selectedTemplate!.emoji, + displayText, _currentPosition!, - notes.isEmpty ? null : notes, _selectedContact!.isChannel ? null : _selectedContact!.publicKey, @@ -701,43 +734,29 @@ class _SarUpdateSheetState extends State { ), ], ), + ), ); } } -/// Marker Type Chip widget - Displays a selectable SAR marker type -class MarkerTypeChip extends StatelessWidget { - final SarMarkerType type; +/// Template Chip widget - Displays a selectable SAR template +class TemplateChip extends StatelessWidget { + final SarTemplate template; final bool isSelected; final VoidCallback onTap; - const MarkerTypeChip({ + const TemplateChip({ super.key, - required this.type, + required this.template, required this.isSelected, required this.onTap, }); - Color _getMarkerColor() { - switch (type) { - case SarMarkerType.foundPerson: - return Colors.green; - case SarMarkerType.fire: - return Colors.red; - case SarMarkerType.stagingArea: - return Colors.orange; - case SarMarkerType.object: - return Colors.purple; - case SarMarkerType.unknown: - return Colors.grey; - } - } - @override Widget build(BuildContext context) { - final color = _getMarkerColor(); final theme = Theme.of(context); final colorScheme = theme.colorScheme; + final color = template.color; return InkWell( onTap: onTap, @@ -758,18 +777,35 @@ class MarkerTypeChip extends StatelessWidget { child: Row( children: [ Text( - type.emoji, + template.emoji, style: const TextStyle(fontSize: 32), ), const SizedBox(width: 16), Expanded( - child: Text( - type.getLocalizedName(context), - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: isSelected ? color : colorScheme.onSurface, - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + template.name, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: isSelected ? color : colorScheme.onSurface, + ), + ), + if (template.description.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + template.description, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12, + color: colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + ], + ], ), ), if (isSelected) diff --git a/lib/widgets/sar/sar_template_edit_dialog.dart b/lib/widgets/sar/sar_template_edit_dialog.dart new file mode 100644 index 0000000..595a4df --- /dev/null +++ b/lib/widgets/sar/sar_template_edit_dialog.dart @@ -0,0 +1,321 @@ +import 'package:flutter/material.dart'; +import '../../models/sar_template.dart'; +import '../../l10n/app_localizations.dart'; + +/// Dialog for adding or editing SAR templates +class SarTemplateEditDialog extends StatefulWidget { + final SarTemplate? template; // Null for new template + final Function(SarTemplate) onSave; + + const SarTemplateEditDialog({ + super.key, + this.template, + required this.onSave, + }); + + @override + State createState() => _SarTemplateEditDialogState(); +} + +class _SarTemplateEditDialogState extends State { + late TextEditingController _emojiController; + late TextEditingController _nameController; + late TextEditingController _descriptionController; + late String _selectedColor; + + final List> _colorOptions = [ + {'name': 'Green', 'hex': '#4CAF50'}, + {'name': 'Red', 'hex': '#F44336'}, + {'name': 'Orange', 'hex': '#FF9800'}, + {'name': 'Purple', 'hex': '#9C27B0'}, + {'name': 'Blue', 'hex': '#2196F3'}, + {'name': 'Yellow', 'hex': '#FFC107'}, + {'name': 'Brown', 'hex': '#795548'}, + {'name': 'Gray', 'hex': '#9E9E9E'}, + ]; + + String? _emojiError; + String? _nameError; + + @override + void initState() { + super.initState(); + _emojiController = TextEditingController(text: widget.template?.emoji ?? ''); + _nameController = TextEditingController(text: widget.template?.name ?? ''); + _descriptionController = TextEditingController(text: widget.template?.description ?? ''); + _selectedColor = widget.template?.colorHex ?? '#4CAF50'; + } + + @override + void dispose() { + _emojiController.dispose(); + _nameController.dispose(); + _descriptionController.dispose(); + super.dispose(); + } + + bool _validate() { + final l10n = AppLocalizations.of(context)!; + + setState(() { + _emojiError = null; + _nameError = null; + }); + + bool isValid = true; + + if (_emojiController.text.trim().isEmpty) { + setState(() { + _emojiError = l10n.emojiRequired; + }); + isValid = false; + } + + if (_nameController.text.trim().isEmpty) { + setState(() { + _nameError = l10n.nameRequired; + }); + isValid = false; + } + + return isValid; + } + + void _save() { + if (!_validate()) return; + + final template = SarTemplate( + id: widget.template?.id ?? 'custom_${DateTime.now().millisecondsSinceEpoch}', + emoji: _emojiController.text.trim(), + name: _nameController.text.trim(), + description: _descriptionController.text.trim(), + colorHex: _selectedColor, + isDefault: widget.template?.isDefault ?? false, + ); + + widget.onSave(template); + Navigator.of(context).pop(); + } + + String _getPreview() { + final emoji = _emojiController.text.trim(); + final description = _descriptionController.text.trim(); + if (emoji.isEmpty) return 'S::0,0'; + if (description.isEmpty) return 'S:$emoji:0,0'; + return 'S:$emoji:0,0:$description'; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final l10n = AppLocalizations.of(context)!; + final bottomPadding = MediaQuery.of(context).viewInsets.bottom; + + return Container( + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: DraggableScrollableSheet( + initialChildSize: 0.9, + minChildSize: 0.5, + maxChildSize: 0.95, + expand: false, + builder: (context, scrollController) { + return SingleChildScrollView( + controller: scrollController, + child: Padding( + padding: EdgeInsets.fromLTRB(24, 24, 24, 24 + bottomPadding), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Drag handle + Center( + child: Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: 20), + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + // Header + Text( + widget.template == null ? l10n.addTemplate : l10n.editTemplate, + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + const SizedBox(height: 24), + + // Emoji field + TextField( + controller: _emojiController, + decoration: InputDecoration( + labelText: l10n.templateEmoji, + hintText: '🧑', + errorText: _emojiError, + filled: true, + fillColor: colorScheme.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + prefixIcon: const Icon(Icons.emoji_emotions), + ), + maxLength: 4, + style: const TextStyle(fontSize: 24), + textAlign: TextAlign.center, + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 16), + + // Name field + TextField( + controller: _nameController, + decoration: InputDecoration( + labelText: l10n.templateName, + hintText: l10n.templateNameHint, + errorText: _nameError, + filled: true, + fillColor: colorScheme.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + prefixIcon: const Icon(Icons.label), + ), + maxLength: 30, + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 16), + + // Description field + TextField( + controller: _descriptionController, + decoration: InputDecoration( + labelText: l10n.templateDescription, + hintText: l10n.templateDescriptionHint, + filled: true, + fillColor: colorScheme.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + prefixIcon: const Icon(Icons.description), + ), + maxLength: 100, + maxLines: 2, + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 16), + + // Color picker + Text( + l10n.templateColor, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + const SizedBox(height: 12), + Wrap( + spacing: 12, + runSpacing: 12, + children: _colorOptions.map((colorOption) { + final hex = colorOption['hex'] as String; + final color = Color(int.parse('FF${hex.replaceAll('#', '')}', radix: 16)); + final isSelected = _selectedColor == hex; + + return GestureDetector( + onTap: () => setState(() => _selectedColor = hex), + child: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: Border.all( + color: isSelected ? colorScheme.primary : Colors.transparent, + width: 3, + ), + boxShadow: [ + if (isSelected) + BoxShadow( + color: colorScheme.primary.withValues(alpha: 0.3), + blurRadius: 8, + spreadRadius: 2, + ), + ], + ), + child: isSelected + ? const Icon(Icons.check, color: Colors.white) + : null, + ), + ); + }).toList(), + ), + const SizedBox(height: 24), + + // Preview + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: colorScheme.outline.withValues(alpha: 0.3), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.previewFormat, + style: theme.textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + _getPreview(), + style: TextStyle( + fontFamily: 'monospace', + fontSize: 14, + color: colorScheme.onSurface, + ), + ), + ], + ), + ), + const SizedBox(height: 24), + + // Actions + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.cancel), + ), + const SizedBox(width: 12), + ElevatedButton.icon( + onPressed: _save, + icon: const Icon(Icons.save), + label: Text(l10n.save), + ), + ], + ), + ], + ), + ), + ); + }, + ), + ); + } +}